diff --git a/src/analysis/defaults.ts b/src/analysis/defaults.ts index 693ab3b..ef6820a 100644 --- a/src/analysis/defaults.ts +++ b/src/analysis/defaults.ts @@ -1 +1,122 @@ -// resolveDefaults, enum numeric methods \ No newline at end of file +import type { + TaskScope, + TaskRisk, + TaskImpact, +} from "../schema/enums.js"; +import type { TaskGraphNodeAttributes } from "../schema/graph.js"; +import type { ResolvedTaskAttributes } from "../schema/results.js"; + +// --------------------------------------------------------------------------- +// Numeric mapping tables — match docs/architecture/schemas.md exactly +// --------------------------------------------------------------------------- + +// --- TaskScope → cost/token estimates --- + +const SCOPE_COST_ESTIMATE: Record = { + single: 1.0, + narrow: 2.0, + moderate: 3.0, + broad: 4.0, + system: 5.0, +}; + +const SCOPE_TOKEN_ESTIMATE: Record = { + single: 500, + narrow: 1500, + moderate: 3000, + broad: 6000, + system: 10000, +}; + +// --- TaskRisk → probability/weight --- + +const RISK_SUCCESS_PROBABILITY: Record = { + trivial: 0.98, + low: 0.90, + medium: 0.80, + high: 0.65, + critical: 0.50, +}; + +// --- TaskImpact → weight --- + +const IMPACT_WEIGHT: Record = { + isolated: 1.0, + component: 1.5, + phase: 2.0, + project: 3.0, +}; + +// --------------------------------------------------------------------------- +// Standalone numeric functions +// --------------------------------------------------------------------------- + +/** Maps TaskScope → costEstimate (1.0–5.0). */ +export function scopeCostEstimate(scope: TaskScope): number { + return SCOPE_COST_ESTIMATE[scope]; +} + +/** Maps TaskScope → tokenEstimate (500–10000). */ +export function scopeTokenEstimate(scope: TaskScope): number { + return SCOPE_TOKEN_ESTIMATE[scope]; +} + +/** Maps TaskRisk → successProbability (0.50–0.98). */ +export function riskSuccessProbability(risk: TaskRisk): number { + return RISK_SUCCESS_PROBABILITY[risk]; +} + +/** Maps TaskRisk → riskWeight (0.02–0.50). Guaranteed to equal 1 - riskSuccessProbability(risk). */ +export function riskWeight(risk: TaskRisk): number { + return 1 - riskSuccessProbability(risk); +} + +/** Maps TaskImpact → impactWeight (1.0–3.0). */ +export function impactWeight(impact: TaskImpact): number { + return IMPACT_WEIGHT[impact]; +} + +// --------------------------------------------------------------------------- +// resolveDefaults +// --------------------------------------------------------------------------- + +/** Default fallbacks for unassessed categorical fields (see graph-model.md). */ +const DEFAULT_RISK: TaskRisk = "medium"; +const DEFAULT_SCOPE: TaskScope = "narrow"; +const DEFAULT_IMPACT: TaskImpact = "isolated"; + +/** + * Fills in defaults for unassessed categorical fields and computes derived + * numeric values. + * + * - Categorical fields with defaults (risk, scope, impact) are always resolved. + * - Label-only fields (level, priority, status) remain nullable — no default + * value is assigned. + * - Derived fields (costEstimate, tokenEstimate, successProbability, + * riskWeight, impactWeight) are computed from the resolved categorical values. + * + * @param attrs - Partial node attributes with at least a `name` present. + * @returns Fully resolved attributes ready for analysis. + */ +export function resolveDefaults( + attrs: Partial & Pick, +): ResolvedTaskAttributes { + const risk = attrs.risk ?? DEFAULT_RISK; + const scope = attrs.scope ?? DEFAULT_SCOPE; + const impact = attrs.impact ?? DEFAULT_IMPACT; + + return { + name: attrs.name, + scope, + risk, + impact, + level: attrs.level ?? null, + priority: attrs.priority ?? null, + status: attrs.status ?? null, + costEstimate: scopeCostEstimate(scope), + tokenEstimate: scopeTokenEstimate(scope), + successProbability: riskSuccessProbability(risk), + riskWeight: riskWeight(risk), + impactWeight: impactWeight(impact), + }; +} \ No newline at end of file diff --git a/src/schema/results.ts b/src/schema/results.ts index 2492242..4b7d237 100644 --- a/src/schema/results.ts +++ b/src/schema/results.ts @@ -1,4 +1,18 @@ -import { Type, type Static } from "@alkdev/typebox"; +import { Type, type Static, type TSchema } from "@alkdev/typebox"; +import { + TaskScopeEnum, + TaskRiskEnum, + TaskImpactEnum, + TaskLevelEnum, + TaskPriorityEnum, + TaskStatusEnum, +} from "./enums.js"; + +// --- Nullable helper (also exported from enums.ts, duplicated here for schema locality) --- + +/** Wrap a schema to also accept `null`. */ +const Nullable = (schema: T) => + Type.Union([schema, Type.Null()]); // --- RiskPathResult --- @@ -96,4 +110,32 @@ export const RiskDistributionResult = Type.Object({ unspecified: Type.Array(Type.String()), }); /** Distribution of tasks by risk level */ -export type RiskDistributionResult = Static; \ No newline at end of file +export type RiskDistributionResult = Static; + +// --- ResolvedTaskAttributes --- + +/** + * The output of `resolveDefaults` — all categorical fields resolved to their + * numeric equivalents for use in analysis. + * + * Categorical fields that have defaults (scope, risk, impact) are no longer + * optional — `resolveDefaults` fills them in. Label-only fields (level, + * priority, status) remain nullable since they have no meaningful default. + */ +export const ResolvedTaskAttributes = Type.Object({ + name: Type.String(), + scope: TaskScopeEnum, + risk: TaskRiskEnum, + impact: TaskImpactEnum, + level: Nullable(TaskLevelEnum), + priority: Nullable(TaskPriorityEnum), + status: Nullable(TaskStatusEnum), + // Numeric equivalents (always present after resolution): + costEstimate: Type.Number(), + tokenEstimate: Type.Number(), + successProbability: Type.Number(), + riskWeight: Type.Number(), + impactWeight: Type.Number(), +}); +/** Inferred type for {@link ResolvedTaskAttributes} schema. */ +export type ResolvedTaskAttributes = Static; \ No newline at end of file diff --git a/tasks/implementation/schema/numeric-methods-and-defaults.md b/tasks/implementation/schema/numeric-methods-and-defaults.md index 423c6d6..541ec15 100644 --- a/tasks/implementation/schema/numeric-methods-and-defaults.md +++ b/tasks/implementation/schema/numeric-methods-and-defaults.md @@ -1,7 +1,7 @@ --- id: schema/numeric-methods-and-defaults name: Implement categorical numeric functions and resolveDefaults -status: pending +status: completed depends_on: - schema/enums - schema/graph-schemas @@ -41,8 +41,12 @@ Implement the standalone numeric functions that map categorical enum values to t ## Notes -> To be filled by implementation agent +Floating-point comparison in tests uses `toBeCloseTo` for riskWeight and successProbability due to IEEE 754 precision (e.g., 1 - 0.98 = 0.020000000000000018). ## Summary -> To be filled on completion \ No newline at end of file +Implemented categorical numeric functions and resolveDefaults. +- Modified: `src/analysis/defaults.ts` — 5 standalone numeric functions (scopeCostEstimate, scopeTokenEstimate, riskSuccessProbability, riskWeight, impactWeight) + resolveDefaults +- Modified: `src/schema/results.ts` — added ResolvedTaskAttributes TypeBox schema and type alias +- Created: `test/defaults.test.ts` — 30 tests covering every enum value mapping and resolveDefaults with mixed null/present inputs +- Tests: 30 new, all 218 total passing; lint clean \ No newline at end of file diff --git a/test/defaults.test.ts b/test/defaults.test.ts new file mode 100644 index 0000000..716f3d5 --- /dev/null +++ b/test/defaults.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect } from "vitest"; +import { + scopeCostEstimate, + scopeTokenEstimate, + riskSuccessProbability, + riskWeight, + impactWeight, + resolveDefaults, +} from "../src/analysis/defaults.js"; + +// --------------------------------------------------------------------------- +// scopeCostEstimate / scopeTokenEstimate +// --------------------------------------------------------------------------- + +describe("scopeCostEstimate", () => { + const cases: [string, number][] = [ + ["single", 1.0], + ["narrow", 2.0], + ["moderate", 3.0], + ["broad", 4.0], + ["system", 5.0], + ]; + + for (const [scope, expected] of cases) { + it(`maps "${scope}" → ${expected}`, () => { + expect(scopeCostEstimate(scope as "single" | "narrow" | "moderate" | "broad" | "system")).toBe(expected); + }); + } +}); + +describe("scopeTokenEstimate", () => { + const cases: [string, number][] = [ + ["single", 500], + ["narrow", 1500], + ["moderate", 3000], + ["broad", 6000], + ["system", 10000], + ]; + + for (const [scope, expected] of cases) { + it(`maps "${scope}" → ${expected}`, () => { + expect(scopeTokenEstimate(scope as "single" | "narrow" | "moderate" | "broad" | "system")).toBe(expected); + }); + } +}); + +// --------------------------------------------------------------------------- +// riskSuccessProbability / riskWeight +// --------------------------------------------------------------------------- + +describe("riskSuccessProbability", () => { + const cases: [string, number][] = [ + ["trivial", 0.98], + ["low", 0.90], + ["medium", 0.80], + ["high", 0.65], + ["critical", 0.50], + ]; + + for (const [risk, expected] of cases) { + it(`maps "${risk}" → ${expected}`, () => { + expect(riskSuccessProbability(risk as "trivial" | "low" | "medium" | "high" | "critical")).toBe(expected); + }); + } +}); + +describe("riskWeight", () => { + const cases: [string, number][] = [ + ["trivial", 0.02], + ["low", 0.10], + ["medium", 0.20], + ["high", 0.35], + ["critical", 0.50], + ]; + + for (const [risk, expected] of cases) { + it(`maps "${risk}" → ${expected}`, () => { + expect(riskWeight(risk as "trivial" | "low" | "medium" | "high" | "critical")).toBeCloseTo(expected); + }); + } + + it("equals 1 - riskSuccessProbability for every risk value", () => { + const risks: Array<"trivial" | "low" | "medium" | "high" | "critical"> = [ + "trivial", "low", "medium", "high", "critical", + ]; + for (const r of risks) { + expect(riskWeight(r)).toBeCloseTo(1 - riskSuccessProbability(r)); + } + }); +}); + +// --------------------------------------------------------------------------- +// impactWeight +// --------------------------------------------------------------------------- + +describe("impactWeight", () => { + const cases: [string, number][] = [ + ["isolated", 1.0], + ["component", 1.5], + ["phase", 2.0], + ["project", 3.0], + ]; + + for (const [impact, expected] of cases) { + it(`maps "${impact}" → ${expected}`, () => { + expect(impactWeight(impact as "isolated" | "component" | "phase" | "project")).toBe(expected); + }); + } +}); + +// --------------------------------------------------------------------------- +// resolveDefaults +// --------------------------------------------------------------------------- + +describe("resolveDefaults", () => { + it("fills all defaults when only name is provided", () => { + const result = resolveDefaults({ name: "test-task" }); + + expect(result.name).toBe("test-task"); + // Default categorical values + expect(result.scope).toBe("narrow"); + expect(result.risk).toBe("medium"); + expect(result.impact).toBe("isolated"); + // Derived numeric values from defaults + expect(result.costEstimate).toBe(2.0); + expect(result.tokenEstimate).toBe(1500); + expect(result.successProbability).toBeCloseTo(0.80); + expect(result.riskWeight).toBeCloseTo(0.20); + expect(result.impactWeight).toBe(1.0); + // Label-only fields remain null + expect(result.level).toBeNull(); + expect(result.priority).toBeNull(); + expect(result.status).toBeNull(); + }); + + it("preserves explicitly provided categorical fields", () => { + const result = resolveDefaults({ + name: "big-task", + scope: "system", + risk: "critical", + impact: "project", + }); + + expect(result.scope).toBe("system"); + expect(result.risk).toBe("critical"); + expect(result.impact).toBe("project"); + // Derived from explicit values + expect(result.costEstimate).toBe(5.0); + expect(result.tokenEstimate).toBe(10000); + expect(result.successProbability).toBeCloseTo(0.50); + expect(result.riskWeight).toBeCloseTo(0.50); + expect(result.impactWeight).toBe(3.0); + }); + + it("preserves explicit label-only fields", () => { + const result = resolveDefaults({ + name: "labeled-task", + level: "implementation", + priority: "high", + status: "in-progress", + }); + + expect(result.level).toBe("implementation"); + expect(result.priority).toBe("high"); + expect(result.status).toBe("in-progress"); + // Categorical defaults still applied + expect(result.scope).toBe("narrow"); + expect(result.risk).toBe("medium"); + expect(result.impact).toBe("isolated"); + }); + + it("handles mixed present/absent fields", () => { + const result = resolveDefaults({ + name: "mixed-task", + scope: "broad", + // risk absent → default medium + impact: "component", + level: "planning", + // priority absent → null + // status absent → null + }); + + expect(result.scope).toBe("broad"); + expect(result.risk).toBe("medium"); + expect(result.impact).toBe("component"); + expect(result.level).toBe("planning"); + expect(result.priority).toBeNull(); + expect(result.status).toBeNull(); + + // Derived values: scope=broad, risk=medium (default), impact=component + expect(result.costEstimate).toBe(4.0); + expect(result.tokenEstimate).toBe(6000); + expect(result.successProbability).toBeCloseTo(0.80); + expect(result.riskWeight).toBeCloseTo(0.20); + expect(result.impactWeight).toBe(1.5); + }); + + it("derives riskWeight as 1 - successProbability for resolved risk", () => { + const result = resolveDefaults({ name: "x", risk: "high" }); + expect(result.riskWeight).toBeCloseTo(1 - result.successProbability); + }); +}); \ No newline at end of file