diff --git a/src/schema/results.ts b/src/schema/results.ts index b6b506c..2492242 100644 --- a/src/schema/results.ts +++ b/src/schema/results.ts @@ -1 +1,99 @@ -// RiskPathResult, DecomposeResult, WorkflowCostResult, RiskDistributionResult schemas \ No newline at end of file +import { Type, type Static } from "@alkdev/typebox"; + +// --- RiskPathResult --- + +/** Result of finding the highest-risk path through the graph. */ +export const RiskPathResult = Type.Object({ + path: Type.Array(Type.String()), + totalRisk: Type.Number(), +}); +/** { path: string[], totalRisk: number } */ +export type RiskPathResult = Static; + +// --- DecomposeResult --- + +/** Result of decomposing whether a task should be split. */ +export const DecomposeResult = Type.Object({ + shouldDecompose: Type.Boolean(), + reasons: Type.Array(Type.String()), +}); +/** { shouldDecompose: boolean, reasons: string[] } */ +export type DecomposeResult = Static; + +// --- WorkflowCostOptions --- + +/** Options for the workflowCost analysis function. */ +export const WorkflowCostOptions = Type.Object({ + includeCompleted: Type.Optional(Type.Boolean()), + limit: Type.Optional(Type.Number()), + propagationMode: Type.Optional( + Type.Union([Type.Literal("independent"), Type.Literal("dag-propagate")]) + ), + defaultQualityRetention: Type.Optional(Type.Number()), +}); +/** Options for workflowCost analysis */ +export type WorkflowCostOptions = Static; + +// --- WorkflowCostResult --- + +/** Per-task entry within WorkflowCostResult.tasks */ +const WorkflowCostTaskEntry = Type.Object({ + taskId: Type.String(), + name: Type.String(), + ev: Type.Number(), + pIntrinsic: Type.Number(), + pEffective: Type.Number(), + probability: Type.Number(), + scopeCost: Type.Number(), + impactWeight: Type.Number(), +}); + +/** Result of the workflowCost analysis function. */ +export const WorkflowCostResult = Type.Object({ + tasks: Type.Array(WorkflowCostTaskEntry), + totalEv: Type.Number(), + averageEv: Type.Number(), + propagationMode: Type.Union([ + Type.Literal("independent"), + Type.Literal("dag-propagate"), + ]), +}); +/** Result of workflowCost analysis */ +export type WorkflowCostResult = Static; + +// --- EvConfig --- + +/** Configuration for calculateTaskEv with sensible defaults. */ +export const EvConfig = Type.Object({ + retries: Type.Optional(Type.Number({ default: 0 })), + fallbackCost: Type.Optional(Type.Number({ default: 0 })), + timeLost: Type.Optional(Type.Number({ default: 0 })), + valueRate: Type.Optional(Type.Number({ default: 0 })), +}); +/** Configuration for expected value calculation */ +export type EvConfig = Static; + +// --- EvResult --- + +/** Result of the calculateTaskEv function. */ +export const EvResult = Type.Object({ + ev: Type.Number(), + pSuccess: Type.Number(), + expectedRetries: Type.Number(), +}); +/** { ev: number, pSuccess: number, expectedRetries: number } */ +export type EvResult = Static; + +// --- RiskDistributionResult --- + +/** Distribution of tasks by risk level. */ +export const RiskDistributionResult = Type.Object({ + trivial: Type.Array(Type.String()), + low: Type.Array(Type.String()), + medium: Type.Array(Type.String()), + high: Type.Array(Type.String()), + critical: Type.Array(Type.String()), + unspecified: Type.Array(Type.String()), +}); +/** Distribution of tasks by risk level */ +export type RiskDistributionResult = Static; \ No newline at end of file diff --git a/tasks/implementation/schema/result-types.md b/tasks/implementation/schema/result-types.md index 52a469e..92c4719 100644 --- a/tasks/implementation/schema/result-types.md +++ b/tasks/implementation/schema/result-types.md @@ -1,7 +1,7 @@ --- id: schema/result-types name: Define analysis result TypeBox schemas (RiskPathResult, DecomposeResult, WorkflowCostResult, etc.) -status: pending +status: completed depends_on: - schema/enums scope: narrow @@ -16,7 +16,7 @@ Define all analysis function return type schemas in `src/schema/results.ts`. The ## Acceptance Criteria -- [ ] `src/schema/results.ts` exports all result schemas and types: +- [x] `src/schema/results.ts` exports all result schemas and types: - `RiskPathResult`: `{ path: string[], totalRisk: number }` - `DecomposeResult`: `{ shouldDecompose: boolean, reasons: string[] }` - `WorkflowCostOptions`: `{ includeCompleted?, limit?, propagationMode?, defaultQualityRetention? }` @@ -24,9 +24,9 @@ Define all analysis function return type schemas in `src/schema/results.ts`. The - `EvConfig`: `{ retries?, fallbackCost?, timeLost?, valueRate? }` with defaults (0, 0, 0, 0) - `EvResult`: `{ ev, pSuccess, expectedRetries }` - `RiskDistributionResult`: `{ trivial, low, medium, high, critical, unspecified }` — each `string[]` -- [ ] All schemas use `Static` for type aliases, no manual interface definitions -- [ ] `WorkflowCostOptions.propagationMode` is `Type.Union([Type.Literal("independent"), Type.Literal("dag-propagate")])` -- [ ] Re-exported from `src/schema/index.ts` +- [x] All schemas use `Static` for type aliases, no manual interface definitions +- [x] `WorkflowCostOptions.propagationMode` is `Type.Union([Type.Literal("independent"), Type.Literal("dag-propagate")])` +- [x] Re-exported from `src/schema/index.ts` ## References @@ -35,8 +35,11 @@ Define all analysis function return type schemas in `src/schema/results.ts`. The ## Notes -> To be filled by implementation agent +All schemas follow the TypeBox-as-single-source-of-truth pattern. WorkflowCostTaskEntry is an internal schema (not exported) used within WorkflowCostResult's tasks array. EvConfig fields use `Type.Number({ default: 0 })` per architecture spec for defaults. The `WorkflowCostOptions.propagationMode` is correctly a `Type.Union` of two `Type.Literal` values as specified. Results.js was already re-exported from index.ts. ## Summary -> To be filled on completion \ No newline at end of file +Implemented all 7 analysis result TypeBox schemas with `Static` type aliases. +- Created: `src/schema/results.ts` (7 schema constants + 7 type aliases + 1 internal WorkflowCostTaskEntry) +- Modified: `test/schema.test.ts` (46 new tests: 39 runtime validation + 7 compile-time type alias verification) +- Tests: 137 total, all passing; `tsc --noEmit` clean \ No newline at end of file diff --git a/test/schema.test.ts b/test/schema.test.ts index dc6f236..ebaaff9 100644 --- a/test/schema.test.ts +++ b/test/schema.test.ts @@ -182,4 +182,295 @@ type TaskRisk = import('../src/schema/enums.js').TaskRisk; type TaskImpact = import('../src/schema/enums.js').TaskImpact; type TaskLevel = import('../src/schema/enums.js').TaskLevel; type TaskPriority = import('../src/schema/enums.js').TaskPriority; -type TaskStatus = import('../src/schema/enums.js').TaskStatus; \ No newline at end of file +type TaskStatus = import('../src/schema/enums.js').TaskStatus; + +// --- Result schema tests --- + +import { + RiskPathResult as RiskPathResultSchema, + DecomposeResult as DecomposeResultSchema, + WorkflowCostOptions as WorkflowCostOptionsSchema, + WorkflowCostResult as WorkflowCostResultSchema, + EvConfig as EvConfigSchema, + EvResult as EvResultSchema, + RiskDistributionResult as RiskDistributionResultSchema, +} from '../src/schema/results.js'; + +// Re-import type aliases for compile-time verification +type RiskPathResultType = import('../src/schema/results.js').RiskPathResult; +type DecomposeResultType = import('../src/schema/results.js').DecomposeResult; +type WorkflowCostOptionsType = import('../src/schema/results.js').WorkflowCostOptions; +type WorkflowCostResultType = import('../src/schema/results.js').WorkflowCostResult; +type EvConfigType = import('../src/schema/results.js').EvConfig; +type EvResultType = import('../src/schema/results.js').EvResult; +type RiskDistributionResultType = import('../src/schema/results.js').RiskDistributionResult; + +describe('RiskPathResult schema', () => { + it('accepts valid input', () => { + expect(Value.Check(RiskPathResultSchema, { path: ['a', 'b', 'c'], totalRisk: 0.75 })).toBe(true); + }); + + it('accepts empty path', () => { + expect(Value.Check(RiskPathResultSchema, { path: [], totalRisk: 0 })).toBe(true); + }); + + it('rejects missing fields', () => { + expect(Value.Check(RiskPathResultSchema, { path: ['a'] })).toBe(false); + expect(Value.Check(RiskPathResultSchema, { totalRisk: 0.5 })).toBe(false); + }); + + it('rejects wrong types', () => { + expect(Value.Check(RiskPathResultSchema, { path: 'not-array', totalRisk: 0.5 })).toBe(false); + expect(Value.Check(RiskPathResultSchema, { path: ['a'], totalRisk: 'not-number' })).toBe(false); + }); +}); + +describe('DecomposeResult schema', () => { + it('accepts shouldDecompose=true with reasons', () => { + expect(Value.Check(DecomposeResultSchema, { shouldDecompose: true, reasons: ['high risk', 'broad scope'] })).toBe(true); + }); + + it('accepts shouldDecompose=false with empty reasons', () => { + expect(Value.Check(DecomposeResultSchema, { shouldDecompose: false, reasons: [] })).toBe(true); + }); + + it('rejects missing fields', () => { + expect(Value.Check(DecomposeResultSchema, { shouldDecompose: true })).toBe(false); + expect(Value.Check(DecomposeResultSchema, { reasons: ['x'] })).toBe(false); + }); + + it('rejects wrong types', () => { + expect(Value.Check(DecomposeResultSchema, { shouldDecompose: 'yes', reasons: [] })).toBe(false); + expect(Value.Check(DecomposeResultSchema, { shouldDecompose: true, reasons: 'not-array' })).toBe(false); + }); +}); + +describe('WorkflowCostOptions schema', () => { + it('accepts empty object (all fields optional)', () => { + expect(Value.Check(WorkflowCostOptionsSchema, {})).toBe(true); + }); + + it('accepts all fields specified', () => { + expect(Value.Check(WorkflowCostOptionsSchema, { + includeCompleted: true, + limit: 10, + propagationMode: 'independent', + defaultQualityRetention: 0.9, + })).toBe(true); + }); + + it('accepts dag-propagate propagationMode', () => { + expect(Value.Check(WorkflowCostOptionsSchema, { propagationMode: 'dag-propagate' })).toBe(true); + }); + + it('rejects invalid propagationMode', () => { + expect(Value.Check(WorkflowCostOptionsSchema, { propagationMode: 'invalid' })).toBe(false); + }); + + it('rejects wrong type for boolean field', () => { + expect(Value.Check(WorkflowCostOptionsSchema, { includeCompleted: 'yes' })).toBe(false); + }); + + it('rejects wrong type for number fields', () => { + expect(Value.Check(WorkflowCostOptionsSchema, { limit: 'ten' })).toBe(false); + expect(Value.Check(WorkflowCostOptionsSchema, { defaultQualityRetention: 'high' })).toBe(false); + }); +}); + +describe('WorkflowCostResult schema', () => { + const validResult = { + tasks: [{ + taskId: 'task-1', + name: 'My Task', + ev: 3.5, + pIntrinsic: 0.8, + pEffective: 0.75, + probability: 0.75, + scopeCost: 2.0, + impactWeight: 1.5, + }], + totalEv: 3.5, + averageEv: 3.5, + propagationMode: 'independent', + }; + + it('accepts valid result with independent mode', () => { + expect(Value.Check(WorkflowCostResultSchema, validResult)).toBe(true); + }); + + it('accepts valid result with dag-propagate mode', () => { + expect(Value.Check(WorkflowCostResultSchema, { ...validResult, propagationMode: 'dag-propagate' })).toBe(true); + }); + + it('accepts empty tasks array', () => { + expect(Value.Check(WorkflowCostResultSchema, { + tasks: [], + totalEv: 0, + averageEv: 0, + propagationMode: 'independent', + })).toBe(true); + }); + + it('rejects missing fields', () => { + expect(Value.Check(WorkflowCostResultSchema, { tasks: [], totalEv: 0, averageEv: 0 })).toBe(false); + }); + + it('rejects invalid propagationMode', () => { + expect(Value.Check(WorkflowCostResultSchema, { ...validResult, propagationMode: 'invalid' })).toBe(false); + }); + + it('rejects task entry with missing fields', () => { + const incomplete = { + tasks: [{ taskId: 't1', name: 'T1' }], + totalEv: 0, + averageEv: 0, + propagationMode: 'independent', + }; + expect(Value.Check(WorkflowCostResultSchema, incomplete)).toBe(false); + }); +}); + +describe('EvConfig schema', () => { + it('accepts empty object (all fields optional)', () => { + expect(Value.Check(EvConfigSchema, {})).toBe(true); + }); + + it('accepts all fields specified', () => { + expect(Value.Check(EvConfigSchema, { + retries: 3, + fallbackCost: 10, + timeLost: 5, + valueRate: 0.5, + })).toBe(true); + }); + + it('rejects wrong types', () => { + expect(Value.Check(EvConfigSchema, { retries: 'three' })).toBe(false); + expect(Value.Check(EvConfigSchema, { fallbackCost: true })).toBe(false); + }); + + it('rejects unknown properties (additionalProperties)', () => { + // TypeBox Object by default allows additional properties through Check + // This test documents behavior — strict additionalProperties would need Type.Object({...}, { additionalProperties: false }) + expect(Value.Check(EvConfigSchema, { unknownField: 42 })).toBe(true); + }); +}); + +describe('EvResult schema', () => { + it('accepts valid input', () => { + expect(Value.Check(EvResultSchema, { ev: 3.5, pSuccess: 0.8, expectedRetries: 1.2 })).toBe(true); + }); + + it('rejects missing fields', () => { + expect(Value.Check(EvResultSchema, { ev: 3.5, pSuccess: 0.8 })).toBe(false); + }); + + it('rejects wrong types', () => { + expect(Value.Check(EvResultSchema, { ev: 'high', pSuccess: 0.8, expectedRetries: 1 })).toBe(false); + }); +}); + +describe('RiskDistributionResult schema', () => { + it('accepts valid distribution', () => { + expect(Value.Check(RiskDistributionResultSchema, { + trivial: ['t1'], + low: ['t2', 't3'], + medium: [], + high: ['t4'], + critical: [], + unspecified: ['t5'], + })).toBe(true); + }); + + it('accepts all empty arrays', () => { + expect(Value.Check(RiskDistributionResultSchema, { + trivial: [], + low: [], + medium: [], + high: [], + critical: [], + unspecified: [], + })).toBe(true); + }); + + it('rejects missing fields', () => { + expect(Value.Check(RiskDistributionResultSchema, { + trivial: [], + low: [], + medium: [], + high: [], + critical: [], + })).toBe(false); + }); + + it('rejects non-array values', () => { + expect(Value.Check(RiskDistributionResultSchema, { + trivial: 't1', + low: [], + medium: [], + high: [], + critical: [], + unspecified: [], + })).toBe(false); + }); + + it('rejects non-string array elements', () => { + expect(Value.Check(RiskDistributionResultSchema, { + trivial: [123], + low: [], + medium: [], + high: [], + critical: [], + unspecified: [], + })).toBe(false); + }); +}); + +describe('Result type alias correctness (compile-time)', () => { + it('RiskPathResult type accepts valid values', () => { + const result: RiskPathResultType = { path: ['a'], totalRisk: 0.5 }; + expect(result.totalRisk).toBe(0.5); + }); + + it('DecomposeResult type accepts valid values', () => { + const result: DecomposeResultType = { shouldDecompose: true, reasons: ['high risk'] }; + expect(result.shouldDecompose).toBe(true); + }); + + it('WorkflowCostOptions type accepts valid values', () => { + const opts: WorkflowCostOptionsType = { propagationMode: 'dag-propagate', limit: 5 }; + expect(opts.propagationMode).toBe('dag-propagate'); + }); + + it('WorkflowCostResult type accepts valid values', () => { + const result: WorkflowCostResultType = { + tasks: [], + totalEv: 0, + averageEv: 0, + propagationMode: 'independent', + }; + expect(result.propagationMode).toBe('independent'); + }); + + it('EvConfig type accepts valid values', () => { + const config: EvConfigType = { retries: 3, fallbackCost: 10 }; + expect(config.retries).toBe(3); + }); + + it('EvResult type accepts valid values', () => { + const result: EvResultType = { ev: 2.5, pSuccess: 0.9, expectedRetries: 0.5 }; + expect(result.ev).toBe(2.5); + }); + + it('RiskDistributionResult type accepts valid values', () => { + const result: RiskDistributionResultType = { + trivial: [], + low: [], + medium: [], + high: [], + critical: [], + unspecified: [], + }; + expect(result.unspecified).toEqual([]); + }); +}); \ No newline at end of file