@multileaf/ai-workflow 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,17 @@ All notable changes to this project are documented in this file. The format foll
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.8.0] - 2026-10-20
8
+
9
+ ### Added
10
+
11
+ - Standing-context benchmark: `aiw context-bench` measures the persistent context AI Workflow renders into every agent session (root context file, neutral resources under `.aiw/resources/`, `.aiw/context/` layers) per category, offline and deterministically. Byte and file counts are measured; tokens are estimated (characters/4) and labeled as such; a content hash pins the fixture and the report contains no timestamps. `--save` records a checkpoint under `.aiw/checkpoints/` and `--baseline=<path>` exits nonzero on any token regression, giving context changes a cost gate.
12
+ - Task parity suite: `aiw parity [--tasks=directory]` runs YAML parity tasks (`.aiw/parity/` by default) that pin quality constraints on context assembly. Each task compares the full composed context against the just-in-time task-retrieval assembler and fails, naming the exact fact, when a critical fact or completion marker does not survive retrieval. Exit nonzero on any failure makes it a CI gate for context changes; estimated token cost is reported but never gates here. Inter-stage tasks load real artifacts via `sources` (project-relative paths into any layer) and enforce `required_patterns` — every extracted identifier (e.g. `REQ-\d+` from the approved specification) must survive assembly, guarding the identifiers traceability links depend on.
13
+ - Usage sidecar: `aiw usage-sidecar record/report` maintains a versioned ledger at `.aiw/checkpoints/usage-sidecar.yml` with per-run tokens split into billing tiers (cache-read, fresh-input, cache-write, output), a `measured`/`estimated` label that propagates pessimistically to aggregates, and attribution by model and harness (with version). The report exposes `cache_hit_rate` (cache-read over billable input); dollar conversion is intentionally left out. `aiw scan` auto-records interpreter and embedding usage into the ledger with their real measurement label.
14
+ - Cost attribution: `aiw cost-report` joins the usage sidecar with the traceability graph (`aiw trace`) to report tokens per verified requirement — the outcome-denominated cost metric — with attributed and unattributed tokens shown side by side. Sidecar entries accept `--requirement=REQ-###`, and `parseTraceabilityGraph` round-trips the serialized graph.
15
+ - Model routing table: `aiw routing init|show|resolve --category=name` maintains a static, evidence-linked table at `.aiw/routing.yml`. Rules map task categories to models deterministically; each rule names its evidence, and the resolver reports its advisory level (`evidenced`, `unevidenced-rule`, `default-fallback`, `default-unset`). Routing is advisory only and never invokes a model.
16
+ - Task-lifecycle token usage: `aiw task-usage [--workflow=id-or-ticket]` aggregates sidecar entries correlated by `--workflow` into one task's totals — input tokens (all billing tiers), output tokens, cache hit rate, per-stage breakdown with models, requirements touched, and harness attribution. Tokens are the model-invariant metric, so lifecycle totals stay comparable across model routing changes; `usage-sidecar record` accepts `--workflow` for correlation.
17
+
7
18
  ## [0.7.0] - 2026-10-13
8
19
 
9
20
  ### Added
@@ -0,0 +1,52 @@
1
+ import type { FileSystem } from "./types.js";
2
+ /**
3
+ * Standing-context benchmark (deterministic, offline, zero API cost).
4
+ *
5
+ * Measures the persistent context AI Workflow renders into every agent
6
+ * session: the root context file for the active target, neutral resources
7
+ * under `.aiw/resources/`, and composed context layers under `.aiw/context/`.
8
+ *
9
+ * Honest-measurement rules (see docs/token-efficiency-study-notes.md):
10
+ * - byte and file counts are `measured`; token counts are `estimated`
11
+ * (characters/4) and always labeled as such;
12
+ * - the bundle hash pins the exact fixture content behind every number;
13
+ * - output contains no timestamps or randomness, so reruns are
14
+ * byte-identical for unchanged projects.
15
+ */
16
+ export declare const CONTEXT_BENCH_SCHEMA = 1;
17
+ export declare const ROOT_CONTEXT_FILES: readonly ["AGENTS.md", "CLAUDE.md", "GEMINI.md"];
18
+ export declare const RESOURCE_CATEGORIES: readonly ["skills", "rules", "agents", "hooks", "templates"];
19
+ export type ResourceCategory = (typeof RESOURCE_CATEGORIES)[number];
20
+ export type ContextBenchCategory = ResourceCategory | "root-context" | "context-layers" | "other-resources";
21
+ export declare const CONTEXT_BENCH_CATEGORIES: readonly ContextBenchCategory[];
22
+ export type ContextBenchCategoryMetrics = {
23
+ name: ContextBenchCategory;
24
+ files: number;
25
+ characters: number;
26
+ estimatedTokens: number;
27
+ paths: string[];
28
+ };
29
+ export type ContextBenchReport = {
30
+ schema: number;
31
+ bundleHash: string;
32
+ totalCharacters: number;
33
+ totalEstimatedTokens: number;
34
+ categories: ContextBenchCategoryMetrics[];
35
+ };
36
+ /** Estimate tokens from characters. Always labeled `estimated`, never `measured`. */
37
+ export declare function estimateTokens(text: string): number;
38
+ /** Collect the standing-context report for a project. Deterministic: same files, same bytes, same report. */
39
+ export declare function collectContextBench(root: string, fs: FileSystem): ContextBenchReport;
40
+ export declare function serializeContextBench(report: ContextBenchReport): string;
41
+ /** Parse a previously saved report for baseline comparison. */
42
+ export declare function parseContextBench(content: string): ContextBenchReport;
43
+ export type ContextBenchComparison = {
44
+ bundleChanged: boolean;
45
+ totalTokenDelta: number;
46
+ regressions: string[];
47
+ };
48
+ /**
49
+ * Compare a current report against a saved baseline.
50
+ * Follows the parity-harness rule: any token increase is a regression.
51
+ */
52
+ export declare function compareContextBench(baseline: ContextBenchReport, current: ContextBenchReport): ContextBenchComparison;
@@ -0,0 +1,170 @@
1
+ import { createHash } from "node:crypto";
2
+ import { join } from "node:path";
3
+ /**
4
+ * Standing-context benchmark (deterministic, offline, zero API cost).
5
+ *
6
+ * Measures the persistent context AI Workflow renders into every agent
7
+ * session: the root context file for the active target, neutral resources
8
+ * under `.aiw/resources/`, and composed context layers under `.aiw/context/`.
9
+ *
10
+ * Honest-measurement rules (see docs/token-efficiency-study-notes.md):
11
+ * - byte and file counts are `measured`; token counts are `estimated`
12
+ * (characters/4) and always labeled as such;
13
+ * - the bundle hash pins the exact fixture content behind every number;
14
+ * - output contains no timestamps or randomness, so reruns are
15
+ * byte-identical for unchanged projects.
16
+ */
17
+ export const CONTEXT_BENCH_SCHEMA = 1;
18
+ export const ROOT_CONTEXT_FILES = ["AGENTS.md", "CLAUDE.md", "GEMINI.md"];
19
+ export const RESOURCE_CATEGORIES = ["skills", "rules", "agents", "hooks", "templates"];
20
+ export const CONTEXT_BENCH_CATEGORIES = [
21
+ "root-context",
22
+ ...RESOURCE_CATEGORIES,
23
+ "context-layers",
24
+ "other-resources",
25
+ ];
26
+ /** Estimate tokens from characters. Always labeled `estimated`, never `measured`. */
27
+ export function estimateTokens(text) {
28
+ return Math.ceil(text.length / 4);
29
+ }
30
+ function hashBundle(entries) {
31
+ const hash = createHash("sha256");
32
+ for (const entry of entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)))
33
+ hash.update(`${entry.path}\0${Buffer.byteLength(entry.content, "utf8")}\0`);
34
+ return hash.digest("hex").slice(0, 16);
35
+ }
36
+ function emptyCategory(name) {
37
+ return { name, files: 0, characters: 0, estimatedTokens: 0, paths: [] };
38
+ }
39
+ function addFile(categories, name, path, content) {
40
+ const category = categories.get(name) ?? emptyCategory(name);
41
+ category.files += 1;
42
+ category.characters += content.length;
43
+ category.estimatedTokens += estimateTokens(content);
44
+ category.paths.push(path);
45
+ categories.set(name, category);
46
+ }
47
+ /** Collect the standing-context report for a project. Deterministic: same files, same bytes, same report. */
48
+ export function collectContextBench(root, fs) {
49
+ const categories = new Map();
50
+ const bundle = [];
51
+ const record = (name, path, content) => {
52
+ addFile(categories, name, path, content);
53
+ bundle.push({ path, content });
54
+ };
55
+ for (const file of ROOT_CONTEXT_FILES) {
56
+ const path = join(root, file);
57
+ if (fs.pathType?.(path) === "file")
58
+ record("root-context", file, fs.read(path));
59
+ }
60
+ const resourceRoot = join(root, ".aiw/resources");
61
+ if (fs.pathType?.(resourceRoot) === "directory" && fs.listFiles) {
62
+ for (const relative of fs.listFiles(resourceRoot)) {
63
+ const absolute = join(resourceRoot, relative);
64
+ if (fs.pathType?.(absolute) !== "file")
65
+ continue;
66
+ const top = relative.split(/[\\/]/)[0];
67
+ const name = RESOURCE_CATEGORIES.includes(top)
68
+ ? top
69
+ : "other-resources";
70
+ record(name, `.aiw/resources/${relative.split("\\").join("/")}`, fs.read(absolute));
71
+ }
72
+ }
73
+ const contextRoot = join(root, ".aiw/context");
74
+ if (fs.pathType?.(contextRoot) === "directory") {
75
+ for (const file of (fs.list?.(contextRoot) ?? [])
76
+ .filter((name) => name.endsWith(".md"))
77
+ .sort()) {
78
+ const absolute = join(contextRoot, file);
79
+ if (fs.pathType?.(absolute) !== "file")
80
+ continue;
81
+ record("context-layers", `.aiw/context/${file}`, fs.read(absolute));
82
+ }
83
+ }
84
+ const ordered = CONTEXT_BENCH_CATEGORIES.map((name) => categories.get(name) ?? emptyCategory(name));
85
+ return {
86
+ schema: CONTEXT_BENCH_SCHEMA,
87
+ bundleHash: hashBundle(bundle),
88
+ totalCharacters: ordered.reduce((total, category) => total + category.characters, 0),
89
+ totalEstimatedTokens: ordered.reduce((total, category) => total + category.estimatedTokens, 0),
90
+ categories: ordered,
91
+ };
92
+ }
93
+ export function serializeContextBench(report) {
94
+ const lines = [
95
+ `schema: ${report.schema}`,
96
+ `bundle_hash: ${report.bundleHash}`,
97
+ `total_characters: ${report.totalCharacters}`,
98
+ `total_estimated_tokens: ${report.totalEstimatedTokens}`,
99
+ "token_estimate: characters/4 (estimated; byte counts are measured)",
100
+ "categories:",
101
+ ];
102
+ for (const category of report.categories) {
103
+ lines.push(` - name: ${category.name}`, ` files: ${category.files}`, ` characters: ${category.characters}`, ` estimated_tokens: ${category.estimatedTokens}`);
104
+ if (category.paths.length) {
105
+ lines.push(" paths:");
106
+ lines.push(...category.paths.map((path) => ` - ${path}`));
107
+ }
108
+ }
109
+ return lines.join("\n") + "\n";
110
+ }
111
+ /** Parse a previously saved report for baseline comparison. */
112
+ export function parseContextBench(content) {
113
+ const lines = content.split("\n");
114
+ if (lines[0]?.trim() !== `schema: ${CONTEXT_BENCH_SCHEMA}`)
115
+ throw new Error("Unrecognized context benchmark report schema.");
116
+ const report = {
117
+ schema: CONTEXT_BENCH_SCHEMA,
118
+ bundleHash: requiredValue(lines, "bundle_hash"),
119
+ totalCharacters: Number(requiredValue(lines, "total_characters")),
120
+ totalEstimatedTokens: Number(requiredValue(lines, "total_estimated_tokens")),
121
+ categories: [],
122
+ };
123
+ let current;
124
+ for (const line of lines.slice(1)) {
125
+ const category = line.match(/^ {2}- name: (.+)$/);
126
+ if (category) {
127
+ current = emptyCategory(category[1]);
128
+ report.categories.push(current);
129
+ continue;
130
+ }
131
+ const path = line.match(/^ {6}- (.+)$/);
132
+ if (path && current)
133
+ current.paths.push(path[1]);
134
+ const match = line.match(/^ {4}(files|characters|estimated_tokens): (\d+)$/);
135
+ if (match && current) {
136
+ const key = match[1] === "estimated_tokens" ? "estimatedTokens" : match[1];
137
+ current[key] = Number(match[2]);
138
+ }
139
+ }
140
+ if (!report.categories.length)
141
+ throw new Error("Context benchmark report has no categories.");
142
+ return report;
143
+ }
144
+ function requiredValue(lines, key) {
145
+ const value = lines.find((line) => line.startsWith(`${key}: `))?.slice(key.length + 2);
146
+ if (!value)
147
+ throw new Error(`Context benchmark report is missing ${key}.`);
148
+ return value;
149
+ }
150
+ /**
151
+ * Compare a current report against a saved baseline.
152
+ * Follows the parity-harness rule: any token increase is a regression.
153
+ */
154
+ export function compareContextBench(baseline, current) {
155
+ const baselineByCategory = new Map(baseline.categories.map((category) => [category.name, category]));
156
+ const regressions = [];
157
+ for (const category of current.categories) {
158
+ const previous = baselineByCategory.get(category.name);
159
+ if (previous && category.estimatedTokens > previous.estimatedTokens)
160
+ regressions.push(`${category.name}: ${previous.estimatedTokens} -> ${category.estimatedTokens} estimated tokens`);
161
+ }
162
+ const totalTokenDelta = current.totalEstimatedTokens - baseline.totalEstimatedTokens;
163
+ if (totalTokenDelta > 0)
164
+ regressions.push(`total: ${baseline.totalEstimatedTokens} -> ${current.totalEstimatedTokens} estimated tokens`);
165
+ return {
166
+ bundleChanged: baseline.bundleHash !== current.bundleHash,
167
+ totalTokenDelta,
168
+ regressions,
169
+ };
170
+ }
@@ -0,0 +1,65 @@
1
+ import { type UsageLabel, type UsageSidecarEntry } from "./usage-sidecar.js";
2
+ import type { TraceabilityGraph } from "./traceability.js";
3
+ /**
4
+ * Cost attribution per verified requirement — the outcome-denominated cost
5
+ * metric (Uber's "cost per merged PR", adapted to AI Workflow's unit of
6
+ * value: a requirement with verification evidence).
7
+ *
8
+ * Honesty rules:
9
+ * - tokens attributed to requirements and unattributed tokens are reported
10
+ * side by side, so the ratio cannot be gamed by leaving expensive work
11
+ * unattributed;
12
+ * - the denominator counts only requirements the traceability graph marks
13
+ * as verified (linked evidence present);
14
+ * - labels propagate pessimistically (any estimated entry poisons the total).
15
+ */
16
+ export type RequirementCost = {
17
+ requirement: string;
18
+ tokens: number;
19
+ label: UsageLabel;
20
+ verified: boolean;
21
+ };
22
+ export type CostAttribution = {
23
+ attributed: RequirementCost[];
24
+ unattributedTokens: number;
25
+ unattributedLabel: UsageLabel;
26
+ verifiedRequirements: number;
27
+ unverifiedRequirements: number;
28
+ totalTokens: number;
29
+ totalLabel: UsageLabel;
30
+ /** Outcome-denominated metric: attributed tokens per verified requirement. */
31
+ tokensPerVerifiedRequirement?: number;
32
+ };
33
+ export declare function computeCostAttribution(entries: UsageSidecarEntry[], graph: TraceabilityGraph): CostAttribution;
34
+ export declare function serializeCostAttribution(attribution: CostAttribution): string;
35
+ /**
36
+ * Task-lifecycle usage: total tokens from task start to merged PR.
37
+ *
38
+ * Tokens — not dollars — are the model-invariant metric: switching between a
39
+ * frontier and an entry-level model changes the price per token but not the
40
+ * input/output consumption, so lifecycle token totals stay comparable across
41
+ * model routing changes and harness migrations. All input tiers count as
42
+ * input tokens processed (cached reads still consume tokens; they just bill
43
+ * less), and the tier split stays visible for cache-behavior analysis.
44
+ */
45
+ export type TaskUsageStage = {
46
+ stage: string;
47
+ tokens: number;
48
+ label: UsageLabel;
49
+ models: string[];
50
+ };
51
+ export type TaskUsageSummary = {
52
+ workflow: string;
53
+ entries: number;
54
+ inputTokens: number;
55
+ outputTokens: number;
56
+ totalTokens: number;
57
+ label: UsageLabel;
58
+ cacheHitRate?: number;
59
+ stages: TaskUsageStage[];
60
+ requirements: string[];
61
+ models: string[];
62
+ harnesses: string[];
63
+ };
64
+ export declare function summarizeTaskUsage(entries: UsageSidecarEntry[], workflow: string): TaskUsageSummary;
65
+ export declare function serializeTaskUsage(summary: TaskUsageSummary): string;
@@ -0,0 +1,119 @@
1
+ import { USAGE_TIERS } from "./usage-sidecar.js";
2
+ const totalTokens = (entry) => USAGE_TIERS.reduce((total, tier) => total + entry.tiers[tier], 0);
3
+ export function computeCostAttribution(entries, graph) {
4
+ const verifiedByRequirement = new Map(graph.links.map((link) => [link.requirement, link.evidence.length > 0]));
5
+ const byRequirement = new Map();
6
+ const unattributed = [];
7
+ for (const entry of entries) {
8
+ if (entry.requirement) {
9
+ const bucket = byRequirement.get(entry.requirement) ?? [];
10
+ bucket.push(entry);
11
+ byRequirement.set(entry.requirement, bucket);
12
+ }
13
+ else
14
+ unattributed.push(entry);
15
+ }
16
+ const labelOf = (group) => group.every((entry) => entry.label === "measured") ? "measured" : "estimated";
17
+ const attributed = [...byRequirement]
18
+ .map(([requirement, group]) => ({
19
+ requirement,
20
+ tokens: group.reduce((total, entry) => total + totalTokens(entry), 0),
21
+ label: labelOf(group),
22
+ verified: verifiedByRequirement.get(requirement) ?? false,
23
+ }))
24
+ .sort((a, b) => b.tokens - a.tokens || a.requirement.localeCompare(b.requirement));
25
+ const graphRequirements = new Set(graph.links.map((link) => link.requirement));
26
+ const verifiedRequirements = attributed.filter(({ verified }) => verified).length;
27
+ const unverifiedRequirements = attributed.filter(({ requirement }) => graphRequirements.has(requirement)).length -
28
+ verifiedRequirements;
29
+ const attributedToVerified = attributed
30
+ .filter(({ verified }) => verified)
31
+ .reduce((total, entry) => total + entry.tokens, 0);
32
+ return {
33
+ attributed,
34
+ unattributedTokens: unattributed.reduce((total, entry) => total + totalTokens(entry), 0),
35
+ unattributedLabel: labelOf(unattributed),
36
+ verifiedRequirements,
37
+ unverifiedRequirements,
38
+ totalTokens: entries.reduce((total, entry) => total + totalTokens(entry), 0),
39
+ totalLabel: labelOf(entries),
40
+ ...(verifiedRequirements > 0
41
+ ? { tokensPerVerifiedRequirement: Math.ceil(attributedToVerified / verifiedRequirements) }
42
+ : {}),
43
+ };
44
+ }
45
+ export function serializeCostAttribution(attribution) {
46
+ if (!attribution.attributed.length && !attribution.unattributedTokens)
47
+ return "No usage recorded against requirements yet. Record with `aiw usage-sidecar record --requirement=REQ-###`.";
48
+ const lines = [
49
+ `total_tokens: ${attribution.totalTokens.toLocaleString("en-US")} (${attribution.totalLabel})`,
50
+ `unattributed_tokens: ${attribution.unattributedTokens.toLocaleString("en-US")} (${attribution.unattributedLabel})`,
51
+ `verified_requirements: ${attribution.verifiedRequirements}`,
52
+ `unverified_requirements: ${attribution.unverifiedRequirements}`,
53
+ ...(attribution.tokensPerVerifiedRequirement === undefined
54
+ ? ["tokens_per_verified_requirement: n/a (no verified requirements yet)"]
55
+ : [
56
+ `tokens_per_verified_requirement: ${attribution.tokensPerVerifiedRequirement.toLocaleString("en-US")} (estimated; outcome-denominated)`,
57
+ ]),
58
+ "by requirement:",
59
+ ...attribution.attributed.map(({ requirement, tokens, label, verified }) => ` - ${requirement}: ${tokens.toLocaleString("en-US")} tokens (${label}, ${verified ? "verified" : "unverified"})`),
60
+ ];
61
+ return lines.join("\n");
62
+ }
63
+ export function summarizeTaskUsage(entries, workflow) {
64
+ const scoped = entries.filter((entry) => entry.workflow === workflow);
65
+ const labelOf = (group) => group.every((entry) => entry.label === "measured") ? "measured" : "estimated";
66
+ const inputTokens = scoped.reduce((total, entry) => total + entry.tiers["cache-read"] + entry.tiers["fresh-input"] + entry.tiers["cache-write"], 0);
67
+ const billableInput = scoped.reduce((total, entry) => total + entry.tiers["cache-read"] + entry.tiers["fresh-input"], 0);
68
+ const byCommand = new Map();
69
+ for (const entry of scoped) {
70
+ const bucket = byCommand.get(entry.command) ?? [];
71
+ bucket.push(entry);
72
+ byCommand.set(entry.command, bucket);
73
+ }
74
+ const stages = [...byCommand]
75
+ .map(([stage, group]) => ({
76
+ stage,
77
+ tokens: group.reduce((total, entry) => total + totalTokens(entry), 0),
78
+ label: labelOf(group),
79
+ models: [...new Set(group.map((entry) => entry.model ?? "unattributed"))].sort(),
80
+ }))
81
+ .sort((a, b) => b.tokens - a.tokens || a.stage.localeCompare(b.stage));
82
+ const uniqueSorted = (values) => [...new Set(values.filter((value) => value !== undefined))].sort();
83
+ return {
84
+ workflow,
85
+ entries: scoped.length,
86
+ inputTokens,
87
+ outputTokens: scoped.reduce((total, entry) => total + entry.tiers.output, 0),
88
+ totalTokens: scoped.reduce((total, entry) => total + totalTokens(entry), 0),
89
+ label: labelOf(scoped),
90
+ ...(billableInput > 0
91
+ ? {
92
+ cacheHitRate: scoped.reduce((total, entry) => total + entry.tiers["cache-read"], 0) / billableInput,
93
+ }
94
+ : {}),
95
+ stages,
96
+ requirements: uniqueSorted(scoped.map((entry) => entry.requirement)),
97
+ models: uniqueSorted(scoped.map((entry) => entry.model)),
98
+ harnesses: uniqueSorted(scoped.map((entry) => entry.harness)),
99
+ };
100
+ }
101
+ export function serializeTaskUsage(summary) {
102
+ if (!summary.entries)
103
+ return `No usage recorded for workflow ${summary.workflow}. Correlate runs with \`aiw usage-sidecar record --workflow=${summary.workflow}\`.`;
104
+ return [
105
+ `workflow: ${summary.workflow} (${summary.entries} entries, ${summary.label})`,
106
+ `input_tokens: ${summary.inputTokens.toLocaleString("en-US")} (cache-read + fresh-input + cache-write)`,
107
+ `output_tokens: ${summary.outputTokens.toLocaleString("en-US")}`,
108
+ `total_tokens: ${summary.totalTokens.toLocaleString("en-US")}`,
109
+ ...(summary.cacheHitRate === undefined
110
+ ? []
111
+ : [`cache_hit_rate: ${Math.round(summary.cacheHitRate * 100)}%`]),
112
+ ...(summary.models.length ? [`models: ${summary.models.join(", ")}`] : []),
113
+ ...(summary.harnesses.length ? [`harnesses: ${summary.harnesses.join(", ")}`] : []),
114
+ ...(summary.requirements.length ? [`requirements: ${summary.requirements.join(", ")}`] : []),
115
+ "by stage:",
116
+ ...summary.stages.map(({ stage, tokens, label, models }) => ` - ${stage}: ${tokens.toLocaleString("en-US")} tokens (${label}${models.length ? `; ${models.join(", ")}` : ""})`),
117
+ "metric: tokens are model-invariant — comparable across model routing changes and harness migrations.",
118
+ ].join("\n");
119
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Static model routing table — advisory, versioned, evidence-linked.
3
+ *
4
+ * Research consensus (docs/token-efficiency-study-notes.md §1 and §5):
5
+ * optimal routing is usually static — per-request dynamism mostly adds
6
+ * failure modes. Rules follow the ModelFit discipline: every recommendation
7
+ * names the evidence behind it, execution is advisory only, and humans can
8
+ * always override. The table never invokes a model; it answers "which model
9
+ * should this category use, and why".
10
+ */
11
+ export declare const ROUTING_TABLE_SCHEMA = 1;
12
+ export declare const ROUTING_TABLE_PATH = "routing.yml";
13
+ export declare const ROUTING_DEFAULT_UNSET = "unset";
14
+ export type RoutingRule = {
15
+ category: string;
16
+ model: string;
17
+ /** Named evidence (benchmark, parity run, ADR) backing this rule. Empty means unevidenced. */
18
+ evidence: string;
19
+ };
20
+ export type RoutingTable = {
21
+ schema: number;
22
+ default: string;
23
+ rules: RoutingRule[];
24
+ };
25
+ export type ResolvedRoute = {
26
+ category: string;
27
+ model: string;
28
+ matchedRule: boolean;
29
+ evidence?: string;
30
+ advisory: "evidenced" | "unevidenced-rule" | "default-fallback" | "default-unset";
31
+ };
32
+ export declare function validateRoutingTable(table: RoutingTable): RoutingTable;
33
+ export declare function defaultRoutingTable(): RoutingTable;
34
+ export declare function parseRoutingTable(content: string): RoutingTable;
35
+ export declare function serializeRoutingTable(table: RoutingTable): string;
36
+ /** Resolve a category to a model. Deterministic: same table, same category, same answer. */
37
+ export declare function resolveRoute(table: RoutingTable, category: string): ResolvedRoute;
38
+ export declare function serializeResolvedRoute(route: ResolvedRoute): string;
@@ -0,0 +1,116 @@
1
+ import { parseDocument } from "yaml";
2
+ /**
3
+ * Static model routing table — advisory, versioned, evidence-linked.
4
+ *
5
+ * Research consensus (docs/token-efficiency-study-notes.md §1 and §5):
6
+ * optimal routing is usually static — per-request dynamism mostly adds
7
+ * failure modes. Rules follow the ModelFit discipline: every recommendation
8
+ * names the evidence behind it, execution is advisory only, and humans can
9
+ * always override. The table never invokes a model; it answers "which model
10
+ * should this category use, and why".
11
+ */
12
+ export const ROUTING_TABLE_SCHEMA = 1;
13
+ export const ROUTING_TABLE_PATH = "routing.yml";
14
+ export const ROUTING_DEFAULT_UNSET = "unset";
15
+ const CATEGORY_PATTERN = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*$/;
16
+ function validateRule(rule) {
17
+ if (!CATEGORY_PATTERN.test(rule.category))
18
+ throw new Error(`Routing category must be dot-separated kebab-case: ${rule.category}.`);
19
+ if (!rule.model.trim())
20
+ throw new Error(`Routing rule for ${rule.category} needs a model.`);
21
+ }
22
+ export function validateRoutingTable(table) {
23
+ if (table.schema !== ROUTING_TABLE_SCHEMA)
24
+ throw new Error(`Routing table schema must be version ${ROUTING_TABLE_SCHEMA}.`);
25
+ if (!table.default.trim())
26
+ throw new Error("Routing table default model must not be empty.");
27
+ table.rules.forEach(validateRule);
28
+ const categories = table.rules.map((rule) => rule.category);
29
+ if (new Set(categories).size !== categories.length)
30
+ throw new Error("Routing table categories must be unique.");
31
+ return table;
32
+ }
33
+ export function defaultRoutingTable() {
34
+ return validateRoutingTable({
35
+ schema: ROUTING_TABLE_SCHEMA,
36
+ default: ROUTING_DEFAULT_UNSET,
37
+ rules: [],
38
+ });
39
+ }
40
+ export function parseRoutingTable(content) {
41
+ const document = parseDocument(content);
42
+ if (document.errors.length)
43
+ throw new Error("Routing table is not valid YAML.");
44
+ const schema = document.get("schema");
45
+ if (schema !== ROUTING_TABLE_SCHEMA)
46
+ throw new Error(`Routing table schema must be version ${ROUTING_TABLE_SCHEMA}.`);
47
+ const pick = (key) => document.get(key);
48
+ const rulesNode = pick("rules");
49
+ const rules = [];
50
+ if (rulesNode !== null && rulesNode !== undefined) {
51
+ const items = Array.isArray(rulesNode)
52
+ ? rulesNode
53
+ : typeof rulesNode.items === "object"
54
+ ? rulesNode.items
55
+ : undefined;
56
+ if (items === undefined)
57
+ throw new Error("Routing table rules must be a list.");
58
+ for (const raw of items) {
59
+ const read = (key) => typeof raw.get === "function"
60
+ ? raw.get(key)
61
+ : raw[key];
62
+ rules.push({
63
+ category: String(read("category")),
64
+ model: String(read("model")),
65
+ evidence: read("evidence") === null || read("evidence") === undefined
66
+ ? ""
67
+ : String(read("evidence")),
68
+ });
69
+ }
70
+ }
71
+ return validateRoutingTable({
72
+ schema: ROUTING_TABLE_SCHEMA,
73
+ default: String(pick("default") ?? ""),
74
+ rules,
75
+ });
76
+ }
77
+ export function serializeRoutingTable(table) {
78
+ validateRoutingTable(table);
79
+ const lines = [`schema: ${table.schema}`, `default: ${table.default}`, "rules:"];
80
+ if (!table.rules.length)
81
+ lines.push(" []");
82
+ for (const rule of table.rules) {
83
+ lines.push(` - category: ${rule.category}`, ` model: ${rule.model}`, ` evidence: ${rule.evidence ? JSON.stringify(rule.evidence) : '""'}`);
84
+ }
85
+ return lines.join("\n") + "\n";
86
+ }
87
+ /** Resolve a category to a model. Deterministic: same table, same category, same answer. */
88
+ export function resolveRoute(table, category) {
89
+ if (!CATEGORY_PATTERN.test(category))
90
+ throw new Error(`Routing category must be dot-separated kebab-case: ${category}.`);
91
+ const rule = table.rules.find((candidate) => candidate.category === category);
92
+ if (rule)
93
+ return {
94
+ category,
95
+ model: rule.model,
96
+ matchedRule: true,
97
+ ...(rule.evidence ? { evidence: rule.evidence } : {}),
98
+ advisory: rule.evidence ? "evidenced" : "unevidenced-rule",
99
+ };
100
+ if (table.default === ROUTING_DEFAULT_UNSET)
101
+ return { category, model: table.default, matchedRule: false, advisory: "default-unset" };
102
+ return { category, model: table.default, matchedRule: false, advisory: "default-fallback" };
103
+ }
104
+ export function serializeResolvedRoute(route) {
105
+ const advisoryText = {
106
+ evidenced: `advisory: rule is evidence-backed (${route.evidence})`,
107
+ "unevidenced-rule": "advisory: rule has no named evidence; benchmark it before trusting the routing",
108
+ "default-fallback": "advisory: no rule for this category; fell back to the table default",
109
+ "default-unset": "advisory: no rule for this category and the default is unset; a human must choose the model",
110
+ };
111
+ return [
112
+ `category: ${route.category}`,
113
+ `model: ${route.model}`,
114
+ advisoryText[route.advisory],
115
+ ].join("\n");
116
+ }
@@ -0,0 +1,67 @@
1
+ import { type ContextLayer, type ContextStore } from "./context-store.js";
2
+ /**
3
+ * Task parity suite (deterministic, offline, zero API cost).
4
+ *
5
+ * Each parity task pins a quality constraint on context assembly: the
6
+ * critical facts and completion markers a task needs must survive the
7
+ * just-in-time assembly path (`retrieveTaskContext`), not only the full
8
+ * composed context. If a fact only exists in a layer that retrieval drops,
9
+ * the task fails — that is a true-positive detection of context loss, not
10
+ * a benchmark artifact (see docs/token-efficiency-study-notes.md, step 2).
11
+ *
12
+ * Cost is reported (estimated, characters/4) but never gates: this suite is
13
+ * the quality gate; the cost gate is `aiw context-bench`.
14
+ */
15
+ export declare const PARITY_TASK_SCHEMA = 1;
16
+ export type ParityTask = {
17
+ schema: number;
18
+ id: string;
19
+ description: string;
20
+ layers: Partial<Record<ContextLayer, string>>;
21
+ criticalFacts: string[];
22
+ completionMarkers: string[];
23
+ /** Project-relative artifact paths loaded into layers before assembly (inter-stage parity). */
24
+ sources?: Partial<Record<ContextLayer, string>>;
25
+ /** Every literal match of these patterns in the loaded sources must survive assembly. */
26
+ requiredPatterns?: string[];
27
+ };
28
+ export type ParityAssembler = {
29
+ name: string;
30
+ assemble(store: ContextStore): string;
31
+ };
32
+ /** The default just-in-time assembly path under test. */
33
+ export declare const TASK_ASSEMBLER: ParityAssembler;
34
+ /** The full-context baseline every assembler is compared against. */
35
+ export declare const FULL_ASSEMBLER: ParityAssembler;
36
+ export type ParityTaskResult = {
37
+ id: string;
38
+ passed: boolean;
39
+ factsTotal: number;
40
+ factsRetained: number;
41
+ missingFacts: string[];
42
+ missingMarkers: string[];
43
+ missingPatterns: string[];
44
+ baselineTokens: number;
45
+ assembledTokens: number;
46
+ tokenDelta: number;
47
+ };
48
+ export type ParitySuiteResult = {
49
+ schema: number;
50
+ assembler: string;
51
+ total: number;
52
+ passed: number;
53
+ failed: number;
54
+ totalBaselineTokens: number;
55
+ totalAssembledTokens: number;
56
+ results: ParityTaskResult[];
57
+ };
58
+ export declare function parseParityTask(content: string): ParityTask;
59
+ /**
60
+ * Load declared artifact sources into their layers (inter-stage parity).
61
+ * Source content is appended after any declared layer content.
62
+ */
63
+ export declare function materializeParityTask(task: ParityTask, readSource: (path: string) => string): ParityTask;
64
+ /** Run one task: baseline (full context) vs the assembler under test. Exits are pass/fail only. */
65
+ export declare function runParityTask(task: ParityTask, assembler?: ParityAssembler): ParityTaskResult;
66
+ export declare function runParitySuite(tasks: ParityTask[], assembler?: ParityAssembler): ParitySuiteResult;
67
+ export declare function serializeParitySuite(result: ParitySuiteResult): string;