tickmarkr 1.30.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/README.md +347 -0
- package/dist/adapters/claude-code.d.ts +4 -0
- package/dist/adapters/claude-code.js +191 -0
- package/dist/adapters/codex.d.ts +8 -0
- package/dist/adapters/codex.js +257 -0
- package/dist/adapters/cursor-agent.d.ts +4 -0
- package/dist/adapters/cursor-agent.js +62 -0
- package/dist/adapters/fake.d.ts +45 -0
- package/dist/adapters/fake.js +115 -0
- package/dist/adapters/grok.d.ts +4 -0
- package/dist/adapters/grok.js +122 -0
- package/dist/adapters/model-lints.d.ts +6 -0
- package/dist/adapters/model-lints.js +130 -0
- package/dist/adapters/opencode.d.ts +3 -0
- package/dist/adapters/opencode.js +108 -0
- package/dist/adapters/pi.d.ts +3 -0
- package/dist/adapters/pi.js +191 -0
- package/dist/adapters/prompt.d.ts +6 -0
- package/dist/adapters/prompt.js +84 -0
- package/dist/adapters/registry.d.ts +31 -0
- package/dist/adapters/registry.js +165 -0
- package/dist/adapters/types.d.ts +98 -0
- package/dist/adapters/types.js +56 -0
- package/dist/cli/commands/approve.d.ts +1 -0
- package/dist/cli/commands/approve.js +72 -0
- package/dist/cli/commands/compile.d.ts +1 -0
- package/dist/cli/commands/compile.js +23 -0
- package/dist/cli/commands/doctor.d.ts +2 -0
- package/dist/cli/commands/doctor.js +103 -0
- package/dist/cli/commands/init.d.ts +1 -0
- package/dist/cli/commands/init.js +32 -0
- package/dist/cli/commands/plan.d.ts +2 -0
- package/dist/cli/commands/plan.js +101 -0
- package/dist/cli/commands/profile.d.ts +1 -0
- package/dist/cli/commands/profile.js +56 -0
- package/dist/cli/commands/report.d.ts +4 -0
- package/dist/cli/commands/report.js +306 -0
- package/dist/cli/commands/resume.d.ts +1 -0
- package/dist/cli/commands/resume.js +11 -0
- package/dist/cli/commands/run.d.ts +1 -0
- package/dist/cli/commands/run.js +35 -0
- package/dist/cli/commands/scope.d.ts +3 -0
- package/dist/cli/commands/scope.js +22 -0
- package/dist/cli/commands/status.d.ts +9 -0
- package/dist/cli/commands/status.js +162 -0
- package/dist/cli/commands/unlock.d.ts +1 -0
- package/dist/cli/commands/unlock.js +11 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.js +65 -0
- package/dist/compile/collateral.d.ts +6 -0
- package/dist/compile/collateral.js +128 -0
- package/dist/compile/common.d.ts +12 -0
- package/dist/compile/common.js +56 -0
- package/dist/compile/gsd.d.ts +3 -0
- package/dist/compile/gsd.js +222 -0
- package/dist/compile/index.d.ts +4 -0
- package/dist/compile/index.js +33 -0
- package/dist/compile/native.d.ts +4 -0
- package/dist/compile/native.js +196 -0
- package/dist/compile/prd.d.ts +2 -0
- package/dist/compile/prd.js +64 -0
- package/dist/compile/speckit.d.ts +2 -0
- package/dist/compile/speckit.js +66 -0
- package/dist/config/config.d.ts +184 -0
- package/dist/config/config.js +329 -0
- package/dist/drivers/herdr.d.ts +44 -0
- package/dist/drivers/herdr.js +394 -0
- package/dist/drivers/index.d.ts +3 -0
- package/dist/drivers/index.js +13 -0
- package/dist/drivers/subprocess.d.ts +25 -0
- package/dist/drivers/subprocess.js +106 -0
- package/dist/drivers/types.d.ts +61 -0
- package/dist/drivers/types.js +77 -0
- package/dist/gates/acceptance.d.ts +19 -0
- package/dist/gates/acceptance.js +92 -0
- package/dist/gates/baseline.d.ts +12 -0
- package/dist/gates/baseline.js +97 -0
- package/dist/gates/evidence.d.ts +4 -0
- package/dist/gates/evidence.js +12 -0
- package/dist/gates/llm.d.ts +25 -0
- package/dist/gates/llm.js +101 -0
- package/dist/gates/review.d.ts +12 -0
- package/dist/gates/review.js +80 -0
- package/dist/gates/run-gates.d.ts +34 -0
- package/dist/gates/run-gates.js +105 -0
- package/dist/gates/scope.d.ts +8 -0
- package/dist/gates/scope.js +32 -0
- package/dist/gates/types.d.ts +6 -0
- package/dist/gates/types.js +1 -0
- package/dist/graph/graph.d.ts +20 -0
- package/dist/graph/graph.js +148 -0
- package/dist/graph/schema.d.ts +185 -0
- package/dist/graph/schema.js +141 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/plan/prompt.d.ts +4 -0
- package/dist/plan/prompt.js +31 -0
- package/dist/plan/scope.d.ts +17 -0
- package/dist/plan/scope.js +141 -0
- package/dist/report/cost.d.ts +24 -0
- package/dist/report/cost.js +89 -0
- package/dist/route/preference.d.ts +27 -0
- package/dist/route/preference.js +49 -0
- package/dist/route/profile.d.ts +65 -0
- package/dist/route/profile.js +197 -0
- package/dist/route/router.d.ts +26 -0
- package/dist/route/router.js +200 -0
- package/dist/run/consult.d.ts +23 -0
- package/dist/run/consult.js +80 -0
- package/dist/run/daemon.d.ts +23 -0
- package/dist/run/daemon.js +728 -0
- package/dist/run/git.d.ts +12 -0
- package/dist/run/git.js +56 -0
- package/dist/run/journal.d.ts +91 -0
- package/dist/run/journal.js +287 -0
- package/dist/run/lock.d.ts +29 -0
- package/dist/run/lock.js +182 -0
- package/dist/run/merge.d.ts +12 -0
- package/dist/run/merge.js +44 -0
- package/dist/run/reconcile.d.ts +4 -0
- package/dist/run/reconcile.js +95 -0
- package/package.json +39 -0
- package/schema/rungraph.schema.json +306 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function scopePrompt(intent, repair) {
|
|
2
|
+
return `DROVR-JUDGE
|
|
3
|
+
You are drafting a drovr native spec from an answered intent. Return only the Markdown spec, with no commentary or code fence.
|
|
4
|
+
|
|
5
|
+
The draft must:
|
|
6
|
+
- start with <!-- drovr:spec -->
|
|
7
|
+
- include a Requirements section whose ids are sequential REQ-01, REQ-02, ...
|
|
8
|
+
- include an Assumptions section that makes every residual uncertainty explicit
|
|
9
|
+
- include a Traceability section mapping every REQ-nn to at least one Tn task
|
|
10
|
+
- use native "## Tn: Title" tasks and only command:, test:, or judge: acceptance entries
|
|
11
|
+
- keep scope guesses advisory in prose; runtime files scope remains authoritative
|
|
12
|
+
|
|
13
|
+
Intent:
|
|
14
|
+
<intent>
|
|
15
|
+
${intent.trim()}
|
|
16
|
+
</intent>
|
|
17
|
+
${repair ? `
|
|
18
|
+
The previous draft failed validation. Repair it without dropping valid detail.
|
|
19
|
+
|
|
20
|
+
Validation errors:
|
|
21
|
+
<errors>
|
|
22
|
+
${repair.error}
|
|
23
|
+
</errors>
|
|
24
|
+
|
|
25
|
+
Previous draft:
|
|
26
|
+
<draft>
|
|
27
|
+
${repair.draft}
|
|
28
|
+
</draft>
|
|
29
|
+
` : ""}
|
|
30
|
+
`;
|
|
31
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { WorkerAdapter } from "../adapters/types.js";
|
|
2
|
+
import type { DrovrConfig } from "../config/config.js";
|
|
3
|
+
import type { ExecutorDriver } from "../drivers/types.js";
|
|
4
|
+
export declare function clarificationGate(intent: string): string[];
|
|
5
|
+
export interface ScopeOptions {
|
|
6
|
+
cfg: DrovrConfig;
|
|
7
|
+
adapters: WorkerAdapter[];
|
|
8
|
+
driver?: ExecutorDriver;
|
|
9
|
+
force?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface ScopeResult {
|
|
12
|
+
specFile: string;
|
|
13
|
+
tasks: number;
|
|
14
|
+
attempts: number;
|
|
15
|
+
}
|
|
16
|
+
export declare function specPathForIntent(intentFile: string): string;
|
|
17
|
+
export declare function scopeIntent(intentFile: string, repoRoot: string, options: ScopeOptions): Promise<ScopeResult>;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { basename, dirname, extname, join } from "node:path";
|
|
4
|
+
import { discoverChannels, getAdapter, probeAll } from "../adapters/registry.js";
|
|
5
|
+
import { compileNative } from "../compile/native.js";
|
|
6
|
+
import { pickDriver } from "../drivers/index.js";
|
|
7
|
+
import { extractJson, runLlm } from "../gates/llm.js";
|
|
8
|
+
import { TaskSchema } from "../graph/schema.js";
|
|
9
|
+
import { route } from "../route/router.js";
|
|
10
|
+
import { scopePrompt } from "./prompt.js";
|
|
11
|
+
const HEADING_RE = /^#{1,6}\s+(.+?)\s*$/;
|
|
12
|
+
const ITEM_RE = /^\s*(?:[-*+]\s+|\d+[.)]\s+)(?:\[[ xX]\]\s*)?(?:[QA]\d*:\s*)?(.+?)\s*$/;
|
|
13
|
+
function sectionItems(source, heading) {
|
|
14
|
+
const items = [];
|
|
15
|
+
let active = false;
|
|
16
|
+
for (const line of source.split("\n")) {
|
|
17
|
+
const h = line.match(HEADING_RE);
|
|
18
|
+
if (h) {
|
|
19
|
+
active = heading.test(h[1]);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (!active)
|
|
23
|
+
continue;
|
|
24
|
+
const item = line.match(ITEM_RE)?.[1].trim();
|
|
25
|
+
if (item)
|
|
26
|
+
items.push(item);
|
|
27
|
+
}
|
|
28
|
+
return items;
|
|
29
|
+
}
|
|
30
|
+
export function clarificationGate(intent) {
|
|
31
|
+
const questions = sectionItems(intent, /^blocking questions?$/i);
|
|
32
|
+
if (questions.length > 3)
|
|
33
|
+
throw new Error(`intent has ${questions.length} blocking questions; at most 3 blocking questions are allowed`);
|
|
34
|
+
const answers = sectionItems(intent, /^(?:blocking )?answers?$/i);
|
|
35
|
+
return questions.filter((_, i) => !answers[i] || /^(?:tbd|todo|unanswered|\?)$/i.test(answers[i]));
|
|
36
|
+
}
|
|
37
|
+
export function specPathForIntent(intentFile) {
|
|
38
|
+
const ext = extname(intentFile);
|
|
39
|
+
const stem = basename(intentFile, ext).replace(/\.intent$/i, "");
|
|
40
|
+
return join(dirname(intentFile), `${stem}.spec.md`);
|
|
41
|
+
}
|
|
42
|
+
function section(source, name) {
|
|
43
|
+
const lines = source.split("\n");
|
|
44
|
+
const start = lines.findIndex((line) => new RegExp(`^#{1,6}\\s+${name}\\s*$`, "i").test(line));
|
|
45
|
+
if (start === -1)
|
|
46
|
+
return "";
|
|
47
|
+
const end = lines.findIndex((line, i) => i > start && HEADING_RE.test(line));
|
|
48
|
+
return lines.slice(start + 1, end === -1 ? undefined : end).join("\n");
|
|
49
|
+
}
|
|
50
|
+
function extractDraft(raw) {
|
|
51
|
+
const clean = raw.replace(/\nDROVR_EXIT:\d[\s\S]*$/, "").trim();
|
|
52
|
+
try {
|
|
53
|
+
const value = JSON.parse(clean);
|
|
54
|
+
if (typeof value === "string")
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// normal adapters return Markdown, not JSON strings
|
|
59
|
+
}
|
|
60
|
+
const json = extractJson(clean);
|
|
61
|
+
const value = json?.spec ?? json?.draft;
|
|
62
|
+
if (typeof value === "string")
|
|
63
|
+
return value;
|
|
64
|
+
const fenced = [...clean.matchAll(/```(?:markdown|md)?\s*\n([\s\S]*?)```/gi)].at(-1)?.[1];
|
|
65
|
+
if (fenced)
|
|
66
|
+
return fenced;
|
|
67
|
+
const marker = clean.indexOf("<!-- drovr:spec");
|
|
68
|
+
return (marker === -1 ? clean : clean.slice(marker)).trimEnd() + "\n";
|
|
69
|
+
}
|
|
70
|
+
function validateDraft(draft) {
|
|
71
|
+
if (!/^<!--\s*drovr:spec(?:\s+v1)?\s*-->/m.test(draft))
|
|
72
|
+
throw new Error("draft is missing the drovr native marker");
|
|
73
|
+
if (!section(draft, "Assumptions").trim())
|
|
74
|
+
throw new Error("draft is missing explicit assumptions");
|
|
75
|
+
const requirements = [...new Set(section(draft, "Requirements").match(/\bREQ-\d{2}\b/g) ?? [])];
|
|
76
|
+
if (!requirements.length)
|
|
77
|
+
throw new Error("draft has no REQ-nn requirements");
|
|
78
|
+
const dir = mkdtempSync(join(tmpdir(), "drovr-scope-compile-"));
|
|
79
|
+
const file = join(dir, "draft.spec.md");
|
|
80
|
+
try {
|
|
81
|
+
writeFileSync(file, draft);
|
|
82
|
+
const graph = compileNative(file);
|
|
83
|
+
const trace = section(draft, "Traceability");
|
|
84
|
+
for (const req of requirements) {
|
|
85
|
+
const mapped = trace.split("\n").some((line) => line.includes(req) && graph.tasks.some((task) => new RegExp(`\\b${task.id}\\b`).test(line)));
|
|
86
|
+
if (!mapped)
|
|
87
|
+
throw new Error(`${req} is not mapped to a task in Traceability`);
|
|
88
|
+
}
|
|
89
|
+
const plain = graph.tasks.flatMap((task) => task.acceptance).filter((item) => typeof item === "string");
|
|
90
|
+
if (plain.length)
|
|
91
|
+
throw new Error(`draft has ${plain.length} untyped acceptance item${plain.length === 1 ? "" : "s"}`);
|
|
92
|
+
return graph.tasks.length;
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
rmSync(dir, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export async function scopeIntent(intentFile, repoRoot, options) {
|
|
99
|
+
if (!existsSync(intentFile))
|
|
100
|
+
throw new Error(`no such intent file: ${intentFile}`);
|
|
101
|
+
const intent = readFileSync(intentFile, "utf8");
|
|
102
|
+
const unanswered = clarificationGate(intent);
|
|
103
|
+
if (unanswered.length) {
|
|
104
|
+
throw new Error(`unanswered blocking questions (${unanswered.length}):\n${unanswered.map((q, i) => `${i + 1}. ${q}`).join("\n")}`);
|
|
105
|
+
}
|
|
106
|
+
const specFile = specPathForIntent(intentFile);
|
|
107
|
+
if (existsSync(specFile) && !options.force)
|
|
108
|
+
throw new Error(`${specFile} already exists; pass --force to overwrite it`);
|
|
109
|
+
const health = await probeAll(options.adapters);
|
|
110
|
+
const channels = discoverChannels(options.cfg, options.adapters, health);
|
|
111
|
+
const planningTask = TaskSchema.parse({
|
|
112
|
+
id: "SCOPE", title: "Draft native spec", goal: "Draft a compiled native spec", shape: "spec", complexity: 7,
|
|
113
|
+
acceptance: [{ oracle: "judge", text: "Every requirement maps to a task with typed acceptance oracles" }],
|
|
114
|
+
});
|
|
115
|
+
const assignment = route(planningTask, options.cfg, channels).assignment;
|
|
116
|
+
const adapter = getAdapter(assignment.adapter, options.adapters);
|
|
117
|
+
const driver = options.cfg.visibility.llm === "pane" ? options.driver ?? pickDriver(options.cfg) : undefined;
|
|
118
|
+
const name = basename(specFile, ".spec.md");
|
|
119
|
+
let prompt = scopePrompt(intent);
|
|
120
|
+
for (let attempts = 1; attempts <= 3; attempts++) {
|
|
121
|
+
const via = driver ? {
|
|
122
|
+
driver, name: `scope-${name}-${attempts}-${adapter.id}`, label: "SCOPE",
|
|
123
|
+
keep: options.cfg.visibility.keepPanes === "forever",
|
|
124
|
+
} : undefined;
|
|
125
|
+
const draft = extractDraft(await runLlm(adapter, assignment.model, prompt, repoRoot, via));
|
|
126
|
+
let tasks;
|
|
127
|
+
try {
|
|
128
|
+
tasks = validateDraft(draft);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
const message = error.message;
|
|
132
|
+
if (attempts === 3)
|
|
133
|
+
throw new Error(`scope draft failed after 2 repair retries:\n${message}`);
|
|
134
|
+
prompt = scopePrompt(intent, { draft, error: message });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
writeFileSync(specFile, draft, options.force ? undefined : { flag: "wx" });
|
|
138
|
+
return { specFile, tasks, attempts };
|
|
139
|
+
}
|
|
140
|
+
throw new Error("unreachable");
|
|
141
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type TokenUsage } from "../adapters/types.js";
|
|
2
|
+
import type { ModelPricing, SubPricing } from "../config/config.js";
|
|
3
|
+
import type { TelemetryRow } from "../run/journal.js";
|
|
4
|
+
export interface CostConfig {
|
|
5
|
+
models?: Record<string, ModelPricing>;
|
|
6
|
+
subs?: Record<string, SubPricing>;
|
|
7
|
+
}
|
|
8
|
+
export interface ChannelCost {
|
|
9
|
+
adapter: string;
|
|
10
|
+
model: string;
|
|
11
|
+
channel: string;
|
|
12
|
+
attempts: number;
|
|
13
|
+
tasks: number;
|
|
14
|
+
tokens?: TokenUsage;
|
|
15
|
+
partialMetering: boolean;
|
|
16
|
+
apiUsd?: number;
|
|
17
|
+
amortizedUsd?: [number, number];
|
|
18
|
+
counterfactualUsd?: number;
|
|
19
|
+
rate?: ModelPricing;
|
|
20
|
+
subPlan?: SubPricing;
|
|
21
|
+
measurable: boolean;
|
|
22
|
+
reason?: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function estimateCosts(rows: TelemetryRow[], cost?: CostConfig): ChannelCost[];
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// v1.20 REC-02: pure cost estimation. Turns telemetry usage rows + an operator-maintained price table
|
|
2
|
+
// into per-channel cost estimates with full basis (tokens counted, rate used, rate date) — justification
|
|
3
|
+
// is data, not prose. ABSENT pricing degrades to "not measurable", never a crash or a fake $0. No network
|
|
4
|
+
// anywhere in the path; the price table is operator config copied from LiteLLM's price JSON (spec cost model).
|
|
5
|
+
import { addUsage } from "../adapters/types.js";
|
|
6
|
+
const round6 = (n) => Math.round(n * 1e6) / 1e6; // kill float dust without faking cents precision
|
|
7
|
+
// tokens × per-Mtok rate. Only dimensions with a configured rate are priced; cacheWrite/reasoning carry no
|
|
8
|
+
// rate field (REC-02) so they stay counted-in-basis but unpriced — honest, not silently inflated.
|
|
9
|
+
function apiCost(t, r) {
|
|
10
|
+
const perMtok = (tokens, rate) => (tokens / 1_000_000) * rate;
|
|
11
|
+
let usd = perMtok(t.input, r.inPerMtok) + perMtok(t.output, r.outPerMtok);
|
|
12
|
+
if (t.cacheRead !== undefined && r.cacheReadPerMtok !== undefined)
|
|
13
|
+
usd += perMtok(t.cacheRead, r.cacheReadPerMtok);
|
|
14
|
+
return round6(usd);
|
|
15
|
+
}
|
|
16
|
+
function estimateGroup(gr, cost) {
|
|
17
|
+
const { adapter, model, channel } = gr[0];
|
|
18
|
+
const attempts = gr.reduce((s, r) => s + (r.attempts ?? 0), 0);
|
|
19
|
+
// fold only rows that metered tokens; absent stays absent (the one sanctioned addUsage coalesce)
|
|
20
|
+
let tokens;
|
|
21
|
+
let metered = 0;
|
|
22
|
+
for (const r of gr)
|
|
23
|
+
if (r.tokens) {
|
|
24
|
+
metered++;
|
|
25
|
+
tokens = addUsage(tokens, r.tokens);
|
|
26
|
+
}
|
|
27
|
+
const partialMetering = metered > 0 && metered < gr.length;
|
|
28
|
+
const rate = cost.models?.[model];
|
|
29
|
+
const subPlan = cost.subs?.[adapter];
|
|
30
|
+
const c = {
|
|
31
|
+
adapter,
|
|
32
|
+
model,
|
|
33
|
+
channel,
|
|
34
|
+
attempts,
|
|
35
|
+
tasks: gr.length,
|
|
36
|
+
partialMetering,
|
|
37
|
+
measurable: false,
|
|
38
|
+
...(tokens ? { tokens } : {}),
|
|
39
|
+
};
|
|
40
|
+
if (channel === "api") {
|
|
41
|
+
if (tokens && rate) {
|
|
42
|
+
c.apiUsd = apiCost(tokens, rate);
|
|
43
|
+
c.rate = rate;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
// fail closed: known rate but no tokens ⇒ unmetered; tokens but no rate ⇒ price unknown
|
|
47
|
+
c.reason = !tokens ? "unmetered — no token usage recorded" : `no API rate configured for model "${model}"`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
// sub channel: amortized range from the plan (a RANGE — time-varying quotas); counterfactual from
|
|
52
|
+
// metered tokens × the model's API rate. Each is independent; either may be absent.
|
|
53
|
+
if (subPlan) {
|
|
54
|
+
// more windows ⇒ cheaper per window ⇒ low uses High and high uses Low
|
|
55
|
+
const perWindowLow = subPlan.planMonthly / subPlan.windowsPerMonthHigh;
|
|
56
|
+
const perWindowHigh = subPlan.planMonthly / subPlan.windowsPerMonthLow;
|
|
57
|
+
c.amortizedUsd = [round6(attempts * perWindowLow), round6(attempts * perWindowHigh)];
|
|
58
|
+
c.subPlan = subPlan;
|
|
59
|
+
}
|
|
60
|
+
if (tokens && rate) {
|
|
61
|
+
c.counterfactualUsd = apiCost(tokens, rate);
|
|
62
|
+
c.rate = rate;
|
|
63
|
+
}
|
|
64
|
+
if (c.amortizedUsd === undefined && c.counterfactualUsd === undefined) {
|
|
65
|
+
// here subPlan is necessarily undefined (else amortized would be set); counterfactual needs tokens+rate
|
|
66
|
+
c.reason = tokens
|
|
67
|
+
? `no sub plan for adapter "${adapter}" and no API rate for model "${model}"`
|
|
68
|
+
: `no sub plan for adapter "${adapter}" and unmetered`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
c.measurable = c.apiUsd !== undefined || c.amortizedUsd !== undefined || c.counterfactualUsd !== undefined;
|
|
72
|
+
return c;
|
|
73
|
+
}
|
|
74
|
+
// Pure: (telemetry rows, price table) → per-channel estimates. Groups by adapter:model (matches the
|
|
75
|
+
// report's existing grouping). Deterministic order: sorted by adapter:model. No I/O, no network.
|
|
76
|
+
export function estimateCosts(rows, cost = {}) {
|
|
77
|
+
const groups = new Map();
|
|
78
|
+
for (const r of rows) {
|
|
79
|
+
const key = `${r.adapter}:${r.model}`;
|
|
80
|
+
const arr = groups.get(key);
|
|
81
|
+
if (arr)
|
|
82
|
+
arr.push(r);
|
|
83
|
+
else
|
|
84
|
+
groups.set(key, [r]);
|
|
85
|
+
}
|
|
86
|
+
return [...groups.entries()]
|
|
87
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
88
|
+
.map(([, gr]) => estimateGroup(gr, cost));
|
|
89
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type AuthHealth } from "../adapters/types.js";
|
|
2
|
+
import type { DrovrConfig } from "../config/config.js";
|
|
3
|
+
export interface Disallowed {
|
|
4
|
+
by: "deny" | "allow";
|
|
5
|
+
entry: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function excludedChannels(cfg: DrovrConfig, adapters: {
|
|
8
|
+
id: string;
|
|
9
|
+
}[] | string[], health: Record<string, AuthHealth>): {
|
|
10
|
+
key: string;
|
|
11
|
+
d: Disallowed;
|
|
12
|
+
}[];
|
|
13
|
+
export declare function exclusionLine(excluded: {
|
|
14
|
+
key: string;
|
|
15
|
+
d: Disallowed;
|
|
16
|
+
}[]): string;
|
|
17
|
+
export declare function preferRanks(c: {
|
|
18
|
+
adapter: string;
|
|
19
|
+
model: string;
|
|
20
|
+
}, cfg: DrovrConfig): {
|
|
21
|
+
shape: string;
|
|
22
|
+
rank: number;
|
|
23
|
+
}[];
|
|
24
|
+
export declare function disallowedBy(c: {
|
|
25
|
+
adapter: string;
|
|
26
|
+
model: string;
|
|
27
|
+
}, routing: DrovrConfig["routing"]): Disallowed | null;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { channelKey, channelsFromConfig } from "../adapters/types.js";
|
|
2
|
+
const adapterIds = (adapters) => typeof adapters[0] === "string" ? adapters : adapters.map((a) => a.id);
|
|
3
|
+
export function excludedChannels(cfg, adapters, health) {
|
|
4
|
+
const { allow, deny } = cfg.routing;
|
|
5
|
+
if (!allow && !deny)
|
|
6
|
+
return [];
|
|
7
|
+
const out = [];
|
|
8
|
+
for (const id of adapterIds(adapters)) {
|
|
9
|
+
if (!health[id]?.installed || !health[id]?.authed)
|
|
10
|
+
continue;
|
|
11
|
+
for (const c of channelsFromConfig(id, cfg)) {
|
|
12
|
+
const d = disallowedBy(c, cfg.routing);
|
|
13
|
+
if (d)
|
|
14
|
+
out.push({ key: channelKey(c), d });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
export function exclusionLine(excluded) {
|
|
20
|
+
const parts = excluded.map(({ key, d }) => `${key} (${d.by}: ${d.entry})`);
|
|
21
|
+
return `routing preference active: ${excluded.length} channel(s) excluded — ${parts.join(", ")}`;
|
|
22
|
+
}
|
|
23
|
+
// T4: the prefer rank a channel holds across the routing map — every shape whose prefer list ranks
|
|
24
|
+
// it, with its 0-based index in that list. Empty ⇒ the channel isn't a prefer target (routes only via
|
|
25
|
+
// tier/floor/cost). Mirrors preferIndex's match grammar (adapter id | channel key) — the authoritative
|
|
26
|
+
// routing comparator in router.ts — never the looser disallowedBy grammar that also bare-matches models.
|
|
27
|
+
export function preferRanks(c, cfg) {
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const [shape, entry] of Object.entries(cfg.routing.map)) {
|
|
30
|
+
const i = (entry.prefer ?? []).findIndex((p) => p === c.adapter || p === channelKey(c));
|
|
31
|
+
if (i !== -1)
|
|
32
|
+
out.push({ shape, rank: i });
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
// entries in either list accept adapter id, model id, or adapter:model (grammar A1)
|
|
37
|
+
export function disallowedBy(c, routing) {
|
|
38
|
+
const { allow, deny } = routing;
|
|
39
|
+
const matches = (e) => e === c.adapter || e === c.model || e === channelKey(c);
|
|
40
|
+
const d = [...(deny?.adapters ?? []), ...(deny?.models ?? [])].find(matches);
|
|
41
|
+
if (d)
|
|
42
|
+
return { by: "deny", entry: d };
|
|
43
|
+
if (allow) {
|
|
44
|
+
const entries = [...(allow.adapters ?? []), ...(allow.models ?? [])];
|
|
45
|
+
if (!entries.some(matches))
|
|
46
|
+
return { by: "allow", entry: entries.join(", ") || "(empty allowlist)" };
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export interface ProfileRow {
|
|
2
|
+
shape: string;
|
|
3
|
+
adapter: string;
|
|
4
|
+
model: string;
|
|
5
|
+
channel: string;
|
|
6
|
+
attempts: number;
|
|
7
|
+
outcome: "done" | "failed" | "human";
|
|
8
|
+
durationMs: number;
|
|
9
|
+
firstAttemptOk?: boolean;
|
|
10
|
+
gateFails?: number;
|
|
11
|
+
consults?: number;
|
|
12
|
+
parkKind?: string;
|
|
13
|
+
runId?: string;
|
|
14
|
+
quotaFailover?: true;
|
|
15
|
+
overrun?: true;
|
|
16
|
+
}
|
|
17
|
+
export interface ProfileCell {
|
|
18
|
+
n: number;
|
|
19
|
+
qSum: number;
|
|
20
|
+
dispatches: number;
|
|
21
|
+
doneCount: number;
|
|
22
|
+
quotaHits: number;
|
|
23
|
+
doneMedianMs?: number;
|
|
24
|
+
nRaw?: number;
|
|
25
|
+
overruns?: number;
|
|
26
|
+
}
|
|
27
|
+
export interface RoutingProfile {
|
|
28
|
+
cells: Map<string, ProfileCell>;
|
|
29
|
+
}
|
|
30
|
+
export declare const NEUTRAL = 0;
|
|
31
|
+
export declare const MIN_SAMPLES = 5;
|
|
32
|
+
export declare const PRIOR_K = 3;
|
|
33
|
+
export declare const PERF_WEIGHT = 0.05;
|
|
34
|
+
export declare const AVAIL_WEIGHT = 0.05;
|
|
35
|
+
export declare const OVERRUN_WEIGHT = 0.05;
|
|
36
|
+
export declare const REF_MS = 600000;
|
|
37
|
+
export declare const EXPLORE_CAP = 5;
|
|
38
|
+
export declare const HALF_LIFE_RUNS = 5;
|
|
39
|
+
export declare const DECAY_CAP = 30;
|
|
40
|
+
export declare function decayWeight(age: number, halfLife?: number): number;
|
|
41
|
+
export declare function explorationBonus(cell: ProfileCell | undefined): number;
|
|
42
|
+
export declare function classify(r: ProfileRow): 1 | 0.5 | 0 | null;
|
|
43
|
+
export declare function buildProfile(rows: ProfileRow[], opts?: {
|
|
44
|
+
halfLifeRuns?: number;
|
|
45
|
+
}): RoutingProfile;
|
|
46
|
+
export declare function cellOf(profile: RoutingProfile | undefined, shape: string, chKey: string, channel: string): ProfileCell | undefined;
|
|
47
|
+
export declare function cellsOf(profile: RoutingProfile): Generator<{
|
|
48
|
+
shape: string;
|
|
49
|
+
chKey: string;
|
|
50
|
+
channel: string;
|
|
51
|
+
cell: ProfileCell;
|
|
52
|
+
}>;
|
|
53
|
+
export interface CellSummary {
|
|
54
|
+
nRaw: number;
|
|
55
|
+
nEff: number;
|
|
56
|
+
dispatches: number;
|
|
57
|
+
quality?: number;
|
|
58
|
+
quotaHits: number;
|
|
59
|
+
cold: boolean;
|
|
60
|
+
exploreRemaining: number;
|
|
61
|
+
}
|
|
62
|
+
export declare function cellSummary(cell: ProfileCell): CellSummary;
|
|
63
|
+
export declare function learnedScore(profile: RoutingProfile | undefined, shape: string, chKey: string, channel: string, opts?: {
|
|
64
|
+
availWeight?: number;
|
|
65
|
+
}): number;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// v1.6 self-learning routing — Phase 12: pure profile derivation + total scoring.
|
|
2
|
+
// pure: no I/O, no clock, no RNG (grep-pinned by tests/route/profile.test.ts); structural
|
|
3
|
+
// mirror of TelemetryRow & { runId } — route/ must NOT import run/ (layer DAG: run → route).
|
|
4
|
+
// Phase 13 wires learnedScore in as the 4th (last) router sort key; this file only produces it.
|
|
5
|
+
import { channelKey } from "../adapters/types.js";
|
|
6
|
+
// A1 tuning knobs (module constants; promotion to routing.learned.* config is Phase 13's call).
|
|
7
|
+
// Every value preserves totality and exact-neutrality — each is a one-line edit.
|
|
8
|
+
export const NEUTRAL = 0;
|
|
9
|
+
export const MIN_SAMPLES = 5; // n below this ⇒ score exactly 0 ⇒ static sort keys decide (ROUTE-07)
|
|
10
|
+
export const PRIOR_K = 3; // α=β=3 pseudo-count; denom = n + 2·PRIOR_K ≥ 6 > 0 always
|
|
11
|
+
export const PERF_WEIGHT = 0.05; // perf term ∈ (−0.025, +0.025] — strictly below one quality quantum
|
|
12
|
+
export const AVAIL_WEIGHT = 0.05; // conservative default, NOT data-derived; penalty-only — avail ∈ [−AVAIL_WEIGHT, 0],
|
|
13
|
+
// ≤ one quality quantum; a quotaHits=0 cell contributes exactly 0 (pre-ROUTE-12
|
|
14
|
+
// byte-identity for no-throttle profiles)
|
|
15
|
+
export const OVERRUN_WEIGHT = 0.05; // ROUTE-18 (OBS-04): conservative default, NOT data-derived; penalty-only —
|
|
16
|
+
// overrunPen ∈ [−OVERRUN_WEIGHT, 0], ≤ one quality quantum (twin of AVAIL_WEIGHT);
|
|
17
|
+
// an overruns=0 cell contributes exactly 0 (byte-identity for overrun-free profiles).
|
|
18
|
+
export const REF_MS = 600_000; // 10 min; REF_MS/(REF_MS+m) is a total monotone map into (0,1]
|
|
19
|
+
export const EXPLORE_CAP = MIN_SAMPLES; // Phase 14 probe budget in dispatches per (shape × channel); A1 knob (2*MIN_SAMPLES = one edit)
|
|
20
|
+
// ROUTE-11 evidence decay (v1.8). Quality evidence loses influence by run-recency RANK, so a
|
|
21
|
+
// warm-but-bad channel whose luck changed no longer waits the whole RUNS_WINDOW to roll.
|
|
22
|
+
export const HALF_LIFE_RUNS = 5; // conservative default, NOT data-derived (real journal: 18 pre-v1.6 rows — cannot tune)
|
|
23
|
+
// conservative default, NOT data-derived. TWO-part exactness argument:
|
|
24
|
+
// (granularity) caps weights at ≥ 2^-30 — every weight is an exact dyadic, no underflow;
|
|
25
|
+
// (magnitude) weights are multiples of 2^-31 ⇒ qSum stays bit-exact while row-count ≪ 2^21
|
|
26
|
+
// (= 2^(51-DECAY_CAP)); raising DECAY_CAP past ~40 would need a canonical fold order.
|
|
27
|
+
export const DECAY_CAP = 30;
|
|
28
|
+
// w = 2 ** -min(floor(age/halfLife), DECAY_CAP). age/Infinity = 0 ⇒ weight 1 ⇒ decay-off degenerates
|
|
29
|
+
// to v1.7 exactly. Dyadic power-of-two weights are the load-bearing exactness property: w·q (q dyadic)
|
|
30
|
+
// and every partial sum stay exact in IEEE-754, so the fold is order-insensitive by construction.
|
|
31
|
+
export function decayWeight(age, halfLife = HALF_LIFE_RUNS) {
|
|
32
|
+
return 2 ** -Math.min(Math.floor(age / halfLife), DECAY_CAP);
|
|
33
|
+
}
|
|
34
|
+
// Phase 14 exploration bonus: an under-observed channel keeps gathering evidence so early bad luck can't
|
|
35
|
+
// starve it permanently (EXP-01). Total by construction — dispatches is a finite non-negative integer counter,
|
|
36
|
+
// the denominator is a compile-time positive constant; no ln/sqrt/data-dependent divide, so none of the
|
|
37
|
+
// NaN/Infinity hazards that killed UCB and inverse-dispatch. No cell ⇒ 0: an UNKNOWN channel is not
|
|
38
|
+
// "under-observed", it is UNOBSERVED — bonusing it would be a discovery-order fairness scheduler (D2 forbids
|
|
39
|
+
// it) and would flip learned.test.ts Row 7. Fund from dispatches, not n: a channel that dispatched but
|
|
40
|
+
// yielded no quality obs (quota parks) has spent its budget — stop probing it, don't probe it forever.
|
|
41
|
+
// ponytail: rank-decay ships in v1.8 (buildProfile below); remaining ceiling = doneMedianMs staleness
|
|
42
|
+
// (perf term is undecayed) and per-cell EWMA as the finer upgrade path.
|
|
43
|
+
export function explorationBonus(cell) {
|
|
44
|
+
if (!cell)
|
|
45
|
+
return 0;
|
|
46
|
+
return Math.max(0, 1 - cell.dispatches / EXPLORE_CAP); // ∈ [0,1]; exactly 0 once dispatches ≥ cap
|
|
47
|
+
}
|
|
48
|
+
const QUALITY_FAIL_PARKS = new Set(["ladder-exhausted", "attempt-cap", "gate-fail"]);
|
|
49
|
+
// Quality observation per row: 1 clean, 0.5 degraded, 0 verified failure, null excluded/unobserved.
|
|
50
|
+
// Uses gateFails/consults, NOT firstAttemptOk/attempts: firstAttemptOk is `attempt===0` at the global
|
|
51
|
+
// loop counter (daemon.ts:347), which quota failovers bump — it would blame the final channel for
|
|
52
|
+
// another channel's quota. gateFails/consults are the quota-immune "merged clean" signals. A future
|
|
53
|
+
// session must NOT "fix" this back to firstAttemptOk.
|
|
54
|
+
// v1.5 rows: optional fields undefined = UNOBSERVED, never 0/false (journal.ts:21-22 — the poisoning bug).
|
|
55
|
+
export function classify(r) {
|
|
56
|
+
if (r.outcome === "done") {
|
|
57
|
+
if (r.gateFails === undefined)
|
|
58
|
+
return r.attempts === 1 ? 1 : null; // v1.5: >1 attempt = quota-vs-retry ambiguous
|
|
59
|
+
// consults ?? 0 is the ONE deliberate coalesce — safe ONLY behind gateFails !== undefined, because
|
|
60
|
+
// both fields ship together from the same v1.6 write sites (daemon.ts:120,347). Not the banned pattern.
|
|
61
|
+
return r.gateFails === 0 && (r.consults ?? 0) === 0 ? 1 : 0.5;
|
|
62
|
+
}
|
|
63
|
+
if (r.outcome === "human")
|
|
64
|
+
return r.parkKind !== undefined && QUALITY_FAIL_PARKS.has(r.parkKind) ? 0 : null;
|
|
65
|
+
return null; // "failed" exception rows carry channel "-" — structurally excluded below anyway
|
|
66
|
+
}
|
|
67
|
+
// PROF-04/05a: SYMMETRIC channel-class split — `channel` is a REQUIRED param (no default, no api
|
|
68
|
+
// special-casing) so a forgotten arg is a tsc compile error, not a silent "sub". Ceiling: splitting
|
|
69
|
+
// doubles cells per model reachable on both classes; per-cell EXPLORE_CAP/MIN_SAMPLES then means a
|
|
70
|
+
// warm model's evidence can dilute below MIN_SAMPLES per class once api channels warm and fall back to
|
|
71
|
+
// the exactly-neutral score — that is the correct separated-evidence behavior, not a regression.
|
|
72
|
+
const cellKey = (shape, chKey, channel) => `${shape}|${chKey}|${channel}`;
|
|
73
|
+
// ROUTE-15 PARAM-SHAPE: optional trailing opts (not PROF-05 required-param) — default is PROVABLY the module
|
|
74
|
+
// constant and oracle (a) Object.is byte-identity drill guards a wrong default; zero churn on callers.
|
|
75
|
+
export function buildProfile(rows, opts = {}) {
|
|
76
|
+
const halfLife = opts.halfLifeRuns ?? HALF_LIFE_RUNS;
|
|
77
|
+
const cells = new Map();
|
|
78
|
+
const durs = new Map();
|
|
79
|
+
// ROUTE-11 pre-pass: rank the DISTINCT defined runIds present. String sort of run-YYYYMMDD-HHMMSS
|
|
80
|
+
// IS chronological — runId content is NEVER parsed. age = numDistinct-1-ascIndex (newest = 0);
|
|
81
|
+
// runId undefined ⇒ age = numDistinct (strictly oldest, least influence — ROUTE-11a, fail toward
|
|
82
|
+
// least influence). numDistinct = 0 (no row carries a runId) ⇒ every age = 0 ⇒ every weight = 1 ⇒
|
|
83
|
+
// byte-identical v1.7 for runId-free inputs. Ranks are relative to the rows present at build time;
|
|
84
|
+
// a profile is built once per route call from readAllTelemetry, so ages cannot drift within one
|
|
85
|
+
// in-memory profile object.
|
|
86
|
+
const sorted = [...new Set(rows.map((r) => r.runId).filter((id) => id !== undefined))].sort();
|
|
87
|
+
const numDistinct = sorted.length;
|
|
88
|
+
const ascIndex = new Map(sorted.map((id, i) => [id, i]));
|
|
89
|
+
const ageOf = (runId) => runId === undefined ? numDistinct : numDistinct - 1 - ascIndex.get(runId);
|
|
90
|
+
for (const r of rows) {
|
|
91
|
+
if (r.adapter === "-")
|
|
92
|
+
continue; // exception row (daemon.ts:387): no real channel, skip entirely
|
|
93
|
+
const key = cellKey(r.shape, channelKey(r), r.channel);
|
|
94
|
+
let c = cells.get(key);
|
|
95
|
+
if (!c) {
|
|
96
|
+
c = { n: 0, qSum: 0, dispatches: 0, doneCount: 0, quotaHits: 0, nRaw: 0, overruns: 0 };
|
|
97
|
+
cells.set(key, c);
|
|
98
|
+
durs.set(key, []);
|
|
99
|
+
}
|
|
100
|
+
c.dispatches++; // utilization axis: every real-channel row, regardless of classification (UNDECAYED — EXP-04)
|
|
101
|
+
if (r.quotaFailover === true || r.parkKind === "quota")
|
|
102
|
+
c.quotaHits++; // availability fact, never a quality input (ROUTE-12); || not +: a both-marked row counts once
|
|
103
|
+
if (r.overrun === true)
|
|
104
|
+
c.overruns = (c.overruns ?? 0) + 1; // ROUTE-18 no-trailer fact (OBS-04), never a quality input; === true only — absent = unobserved (48-03's write side feeds this)
|
|
105
|
+
const q = classify(r);
|
|
106
|
+
// w is a power of two and q dyadic ({0, 0.5, 1}) ⇒ every product q·w and every partial sum is EXACT
|
|
107
|
+
// in IEEE-754 ⇒ the fold stays order-insensitive (v1.7's invariant EXTENDED, not abandoned). Magnitude
|
|
108
|
+
// headroom: weights are multiples of 2^-31, so exactness holds while row-count ≪ 2^21. nRaw is the
|
|
109
|
+
// integer undecayed observation count (Phase 28) — never enters the score arithmetic.
|
|
110
|
+
if (q !== null) {
|
|
111
|
+
const w = decayWeight(ageOf(r.runId), halfLife);
|
|
112
|
+
c.n += w;
|
|
113
|
+
c.qSum += q * w;
|
|
114
|
+
c.nRaw = (c.nRaw ?? 0) + 1;
|
|
115
|
+
}
|
|
116
|
+
if (r.outcome === "done" && Number.isFinite(r.durationMs) && r.durationMs >= 0) {
|
|
117
|
+
c.doneCount++;
|
|
118
|
+
durs.get(key).push(r.durationMs);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
for (const [key, list] of durs) {
|
|
122
|
+
if (list.length === 0)
|
|
123
|
+
continue;
|
|
124
|
+
const s = list.slice().sort((a, b) => a - b); // median from a sorted copy ⇒ order-insensitive
|
|
125
|
+
const mid = s.length >> 1;
|
|
126
|
+
cells.get(key).doneMedianMs = s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
|
127
|
+
}
|
|
128
|
+
return { cells };
|
|
129
|
+
}
|
|
130
|
+
// The one keyed lookup helper — Phase 13's provenance never hand-builds Map keys.
|
|
131
|
+
export function cellOf(profile, shape, chKey, channel) {
|
|
132
|
+
return profile?.cells.get(cellKey(shape, chKey, channel));
|
|
133
|
+
}
|
|
134
|
+
// Phase 28/VIS-05: the ONLY place besides cellKey that knows the key format. report.ts iterates cells
|
|
135
|
+
// via this and NEVER hand-splits a raw key. PROF-04: chKey is `${adapter}:${model}` (contains ":",
|
|
136
|
+
// never "|"); parse channel from the RIGHT (lastIndexOf "|"), shape from the left.
|
|
137
|
+
export function* cellsOf(profile) {
|
|
138
|
+
for (const key of [...profile.cells.keys()].sort()) {
|
|
139
|
+
const ci = key.lastIndexOf("|");
|
|
140
|
+
const channel = key.slice(ci + 1);
|
|
141
|
+
const rest = key.slice(0, ci);
|
|
142
|
+
const si = rest.indexOf("|");
|
|
143
|
+
yield { shape: rest.slice(0, si), chKey: rest.slice(si + 1), channel, cell: profile.cells.get(key) };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Phase 28/VIS-05: the single arithmetic source for the report — report.ts renders, never derives.
|
|
147
|
+
// nRaw ?? 0 coalesces HERE (report.ts bans `?? 0`); cold/exploreRemaining reuse the router thresholds.
|
|
148
|
+
export function cellSummary(cell) {
|
|
149
|
+
return {
|
|
150
|
+
nRaw: cell.nRaw ?? 0,
|
|
151
|
+
nEff: cell.n,
|
|
152
|
+
dispatches: cell.dispatches,
|
|
153
|
+
quality: cell.n > 0 ? cell.qSum / cell.n : undefined,
|
|
154
|
+
quotaHits: cell.quotaHits,
|
|
155
|
+
cold: cell.n < MIN_SAMPLES,
|
|
156
|
+
exploreRemaining: Math.max(0, EXPLORE_CAP - cell.dispatches),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
// Total by construction: every miss returns the explicit NEUTRAL constant; every denominator is a
|
|
160
|
+
// compile-time-positive constant sum; no Record indexing by row strings (the TIER_RANK[typo] scar).
|
|
161
|
+
// Score 0 defers to the static sort keys, which already encode the benchmark-dated tier seeds — a
|
|
162
|
+
// per-channel numeric prior would give differing n=0 scores and break ROUTE-07's byte-identical cold
|
|
163
|
+
// start. Shrinking toward 0 IS shrinking toward the static prior.
|
|
164
|
+
export function learnedScore(profile, shape, chKey, channel, opts = {}) {
|
|
165
|
+
const availWeight = opts.availWeight ?? AVAIL_WEIGHT;
|
|
166
|
+
const cell = cellOf(profile, shape, chKey, channel);
|
|
167
|
+
if (!cell)
|
|
168
|
+
return NEUTRAL; // unknown channel ⇒ exactly neutral (empty-profile / cold-start leg, ROUTE-07)
|
|
169
|
+
// Cold-start proof (ROUTE-07): n ≤ nRaw ≤ dispatches and doneCount ≤ dispatches by construction, so a
|
|
170
|
+
// thin-dispatch cell (dispatches < MIN_SAMPLES) gates ALL THREE terms to literal +0 ⇒ exactly NEUTRAL.
|
|
171
|
+
// Each term is gated INDEPENDENTLY (no early-exit) so a dispatch-warm/quality-cold HIGH-throttle cell can
|
|
172
|
+
// still reach the negative avail term the old `n < MIN ⇒ NEUTRAL` exit suppressed.
|
|
173
|
+
const quality = cell.n < MIN_SAMPLES ? 0 : (cell.qSum + PRIOR_K) / (cell.n + 2 * PRIOR_K) - 0.5; // denom ≥ 2·PRIOR_K > 0
|
|
174
|
+
// REF_MS/(REF_MS+m), NOT range-normalization (x−min)/(max−min): the latter is 0/0 when all durations
|
|
175
|
+
// are identical. median ≥ 0 by the build-time filter ⇒ denom ≥ REF_MS > 0. The leading `cell.n < MIN_SAMPLES ||`
|
|
176
|
+
// is LOAD-BEARING: a null-classified done row bumps doneCount but NOT n (the `n<MIN ⇏ doneCount<MIN` lemma), so
|
|
177
|
+
// without it a {quotaHits=0, n<MIN, doneCount≥MIN} cell would score a nonzero perf where the old early-exit scored
|
|
178
|
+
// 0 — a silent ROUTE-07 break. With it, every quotaHits=0 cell is byte-identical to pre-ROUTE-12.
|
|
179
|
+
const perf = cell.n < MIN_SAMPLES || cell.doneMedianMs === undefined || cell.doneCount < MIN_SAMPLES
|
|
180
|
+
? 0
|
|
181
|
+
: PERF_WEIGHT * (REF_MS / (REF_MS + cell.doneMedianMs) - 0.5);
|
|
182
|
+
// PENALTY-ONLY availability (ROUTE-12): a throttling channel is less available — deprioritize it without ever
|
|
183
|
+
// treating quota as a quality signal, ejecting it, or predicting quota. quotaHits=0 ⇒ avail 0 (no-throttle
|
|
184
|
+
// byte-identity). Gated on dispatches (≥ MIN_SAMPLES > 0 ⇒ positive denominator, no divide-by-zero) INDEPENDENTLY
|
|
185
|
+
// of n, so the perf n-gate never suppresses the throttle penalty. NOT the symmetric (0.5 − ratio) form.
|
|
186
|
+
// ROUTE-16: penalty-only CONFIRMED v1.9 (see .overseer/DECISIONS.md 2026-07-11) — Option B (symmetric
|
|
187
|
+
// 0.5−ratio availability) NOT adopted: no evidence of over-penalization in the real dogfood;
|
|
188
|
+
// PREFER-STARVATION is an orthogonal hint/exploration finding, not an availability defect.
|
|
189
|
+
const avail = cell.dispatches < MIN_SAMPLES ? 0 : -availWeight * (cell.quotaHits / cell.dispatches); // ∈ [−availWeight, 0]
|
|
190
|
+
// PENALTY-ONLY no-trailer overrun (ROUTE-18, OBS-04): a channel that repeatedly burns a whole dispatch
|
|
191
|
+
// window without ever emitting a trailer is less reliable — deprioritize it without ever treating the overrun
|
|
192
|
+
// fact as a quality signal, ejecting it, or predicting it. overruns=0 ⇒ overrunPen 0 (overrun-free byte-identity).
|
|
193
|
+
// Gated on dispatches (≥ MIN_SAMPLES > 0 ⇒ positive denominator, no divide-by-zero) INDEPENDENTLY of n, mirroring
|
|
194
|
+
// the ROUTE-12 avail term exactly. NOT the symmetric (0.5 − ratio) form (the v1.9 ROUTE-16 penalty-only precedent).
|
|
195
|
+
const overrunPen = cell.dispatches < MIN_SAMPLES ? 0 : -OVERRUN_WEIGHT * ((cell.overruns ?? 0) / cell.dispatches); // ∈ [−OVERRUN_WEIGHT, 0]
|
|
196
|
+
return quality + perf + avail + overrunPen; // score ∈ (−0.625, +0.525), finite for every input
|
|
197
|
+
}
|