feat(schema/numeric-methods-and-defaults): implement categorical numeric functions and resolveDefaults
This commit is contained in:
@@ -1 +1,122 @@
|
|||||||
// resolveDefaults, enum numeric methods
|
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<TaskScope, number> = {
|
||||||
|
single: 1.0,
|
||||||
|
narrow: 2.0,
|
||||||
|
moderate: 3.0,
|
||||||
|
broad: 4.0,
|
||||||
|
system: 5.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCOPE_TOKEN_ESTIMATE: Record<TaskScope, number> = {
|
||||||
|
single: 500,
|
||||||
|
narrow: 1500,
|
||||||
|
moderate: 3000,
|
||||||
|
broad: 6000,
|
||||||
|
system: 10000,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- TaskRisk → probability/weight ---
|
||||||
|
|
||||||
|
const RISK_SUCCESS_PROBABILITY: Record<TaskRisk, number> = {
|
||||||
|
trivial: 0.98,
|
||||||
|
low: 0.90,
|
||||||
|
medium: 0.80,
|
||||||
|
high: 0.65,
|
||||||
|
critical: 0.50,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- TaskImpact → weight ---
|
||||||
|
|
||||||
|
const IMPACT_WEIGHT: Record<TaskImpact, number> = {
|
||||||
|
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<TaskGraphNodeAttributes> & Pick<TaskGraphNodeAttributes, "name">,
|
||||||
|
): 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 = <T extends TSchema>(schema: T) =>
|
||||||
|
Type.Union([schema, Type.Null()]);
|
||||||
|
|
||||||
// --- RiskPathResult ---
|
// --- RiskPathResult ---
|
||||||
|
|
||||||
@@ -97,3 +111,31 @@ export const RiskDistributionResult = Type.Object({
|
|||||||
});
|
});
|
||||||
/** Distribution of tasks by risk level */
|
/** Distribution of tasks by risk level */
|
||||||
export type RiskDistributionResult = Static<typeof RiskDistributionResult>;
|
export type RiskDistributionResult = Static<typeof RiskDistributionResult>;
|
||||||
|
|
||||||
|
// --- 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<typeof ResolvedTaskAttributes>;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: schema/numeric-methods-and-defaults
|
id: schema/numeric-methods-and-defaults
|
||||||
name: Implement categorical numeric functions and resolveDefaults
|
name: Implement categorical numeric functions and resolveDefaults
|
||||||
status: pending
|
status: completed
|
||||||
depends_on:
|
depends_on:
|
||||||
- schema/enums
|
- schema/enums
|
||||||
- schema/graph-schemas
|
- schema/graph-schemas
|
||||||
@@ -41,8 +41,12 @@ Implement the standalone numeric functions that map categorical enum values to t
|
|||||||
|
|
||||||
## Notes
|
## 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
|
## Summary
|
||||||
|
|
||||||
> To be filled on completion
|
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
|
||||||
202
test/defaults.test.ts
Normal file
202
test/defaults.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user