tickmarkr 1.45.0 → 1.47.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.
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { parse } from "yaml";
4
+ import { parse, stringify } from "yaml";
5
5
  import { z } from "zod";
6
6
  import { stateDirName } from "../graph/graph.js";
7
7
  import { SHAPES, TIERS } from "../graph/schema.js";
@@ -20,6 +20,8 @@ export const TierEntrySchema = z.object({
20
20
  vendor: z.string(),
21
21
  channel: z.enum(["sub", "api"]),
22
22
  models: z.record(z.string(), TierEnum),
23
+ // v1.47 T3: optional per-model context-window sizes (tokens). Absent block ⇒ no doctor column, no plan lint.
24
+ windows: z.record(z.string(), z.number().int().positive()).optional(),
23
25
  });
24
26
  // v1.10 FLEET-06: optional routing.allow/deny fleet preference; absent blocks ⇒ byte-identical routing/discovery.
25
27
  // deny wins on conflict; presence of allow (even {} / empty arrays) activates allowlist (fail-closed).
@@ -87,6 +89,15 @@ export const TickmarkrConfigSchema = z.object({
87
89
  halfLifeRuns: z.number().positive().optional(),
88
90
  availWeight: z.number().nonnegative().optional(),
89
91
  }).optional(),
92
+ // E3 (v1.45-T3): optional exploration fence — absent block ⇒ byte-identical to pre-v1.45 routing.
93
+ explore: z.object({
94
+ mode: z.enum(["on", "off"]).optional(),
95
+ excludeShapes: z.array(z.string()).optional(),
96
+ excludeComplexityAtOrAbove: z.number().int().min(1).max(10).nullable().optional(),
97
+ cap: z.number().int().positive().optional(),
98
+ }).optional(),
99
+ // v1.47 T4: per-shape time SLA in minutes — advisory plan lint only; absent ⇒ byte-identical routing.
100
+ sla: z.record(z.string(), z.number().positive()).optional(),
90
101
  // Pre-v1.21 doctor.json lacks per-model verdicts. Keep legacy unknown-is-routable behavior opt-in.
91
102
  allowUnverifiedModels: z.boolean(),
92
103
  allow: PrefBlockSchema.optional(),
@@ -305,6 +316,8 @@ export function configTemplate(overlay) {
305
316
  # migration: frontier
306
317
  # learned: on # default ON (ROUTE-14); cold profile = exact v1.5 static routing, warms per workspace. Set 'off' to pin static routing; preview with 'tickmarkr plan'
307
318
  # learnedTuning: { halfLifeRuns: 5, availWeight: 0.05 } # optional; defaults byte-identical
319
+ # explore: { mode: on, excludeShapes: [], excludeComplexityAtOrAbove: null, cap: 5 } # optional; absent ⇒ byte-identical
320
+ # sla: { implement: 15 } # optional per-shape minutes — advisory plan lint only; absent ⇒ no lint
308
321
  # allow: { adapters: [claude-code, codex] } # optional fleet allowlist; presence activates even if empty (fail-closed)
309
322
  # deny: { models: [codex:gpt-5.5] } # optional fleet denylist; deny beats allow on conflict
310
323
  # # entry grammar: adapter id | model id | adapter:model (entries in either list accept all three forms)
@@ -365,3 +378,277 @@ export function configTemplate(overlay) {
365
378
  const nl = base.indexOf("\n");
366
379
  return `${base.slice(0, nl + 1)}${lines.join("\n")}\n${base.slice(nl + 1)}`;
367
380
  }
381
+ /** Fleet-owned overlay keys — the only config surface `tickmarkr fleet` may write. */
382
+ export const FLEET_OVERLAY_KEYS = ["routing", "tiers"];
383
+ export function repoOverlayPath(repoRoot) {
384
+ return join(repoRoot, stateDirName(repoRoot), "config.yaml");
385
+ }
386
+ export function readOverlayFile(path) {
387
+ const raw = readYaml(path);
388
+ if (raw === undefined || raw === null)
389
+ return {};
390
+ if (typeof raw !== "object" || Array.isArray(raw))
391
+ throw new ConfigError(`invalid overlay at ${path}`);
392
+ return raw;
393
+ }
394
+ export function fleetEditableFromConfig(cfg) {
395
+ const tiers = {};
396
+ for (const [adapter, entry] of Object.entries(cfg.tiers)) {
397
+ tiers[adapter] = {};
398
+ for (const [model, tier] of Object.entries(entry.models))
399
+ tiers[adapter][model] = { tier };
400
+ }
401
+ return {
402
+ denyAdapters: [...(cfg.routing.deny?.adapters ?? [])].sort(),
403
+ denyModels: [...(cfg.routing.deny?.models ?? [])].sort(),
404
+ tiers,
405
+ map: structuredClone(cfg.routing.map),
406
+ floors: { ...cfg.routing.floors },
407
+ };
408
+ }
409
+ function fleetSubset(obj) {
410
+ const out = {};
411
+ for (const k of FLEET_OVERLAY_KEYS) {
412
+ if (obj[k] !== undefined)
413
+ out[k] = obj[k];
414
+ }
415
+ return out;
416
+ }
417
+ /** Resolve which layer last set a dotted fleet path (defaults < global < repo). */
418
+ export function fleetKeyLayer(repoRoot, dotted, opts = {}) {
419
+ const gdir = opts.globalDir ?? globalConfigDir();
420
+ const globalRaw = readOverlayFile(join(gdir, "config.yaml"));
421
+ const repoRaw = readOverlayFile(repoOverlayPath(repoRoot));
422
+ const parts = dotted.split(".");
423
+ const at = (layer) => {
424
+ let cur = layer;
425
+ for (const p of parts) {
426
+ if (cur === undefined || cur === null || typeof cur !== "object" || Array.isArray(cur))
427
+ return undefined;
428
+ cur = cur[p];
429
+ }
430
+ return cur;
431
+ };
432
+ if (at(repoRaw) !== undefined)
433
+ return "repo";
434
+ if (at(globalRaw) !== undefined)
435
+ return "global";
436
+ return "defaults";
437
+ }
438
+ /** Non-interactive fleet state for CI drift checks (`tickmarkr fleet --print`). */
439
+ export function formatFleetPrint(repoRoot, opts = {}) {
440
+ const gdir = opts.globalDir ?? globalConfigDir();
441
+ const effective = loadConfig(repoRoot, { globalDir: gdir });
442
+ const editable = fleetEditableFromConfig(effective);
443
+ const lines = ["# tickmarkr fleet — effective state (repo > global > defaults)"];
444
+ const annotate = (dotted, yaml) => `${yaml} # ${fleetKeyLayer(repoRoot, dotted, opts)}`;
445
+ const denyAdapters = editable.denyAdapters;
446
+ const denyModels = editable.denyModels;
447
+ if (denyAdapters.length || denyModels.length) {
448
+ lines.push("routing:");
449
+ lines.push(" deny:");
450
+ if (denyAdapters.length)
451
+ lines.push(annotate("routing.deny.adapters", ` adapters: ${stringify(denyAdapters).trim()}`));
452
+ if (denyModels.length)
453
+ lines.push(annotate("routing.deny.models", ` models: ${stringify(denyModels).trim()}`));
454
+ }
455
+ const mapKeys = Object.keys(editable.map).sort();
456
+ if (mapKeys.length) {
457
+ if (!lines.some((l) => l === "routing:"))
458
+ lines.push("routing:");
459
+ lines.push(" map:");
460
+ for (const shape of mapKeys) {
461
+ const entry = editable.map[shape];
462
+ const body = stringify(entry, { indent: 4 }).trim().split("\n").map((l) => ` ${l}`).join("\n");
463
+ lines.push(annotate(`routing.map.${shape}`, ` ${shape}:`));
464
+ lines.push(body.split("\n").slice(1).join("\n"));
465
+ }
466
+ }
467
+ const floorKeys = Object.keys(editable.floors).sort();
468
+ if (floorKeys.length) {
469
+ if (!lines.some((l) => l === "routing:"))
470
+ lines.push("routing:");
471
+ lines.push(annotate("routing.floors", ` floors: ${stringify(Object.fromEntries(floorKeys.map((k) => [k, editable.floors[k]]))).trim().replace(/^/gm, " ")}`));
472
+ }
473
+ const tierAdapters = Object.keys(editable.tiers).sort();
474
+ if (tierAdapters.length) {
475
+ lines.push("tiers:");
476
+ for (const adapter of tierAdapters) {
477
+ const models = editable.tiers[adapter];
478
+ const modelIds = Object.keys(models).sort();
479
+ if (!modelIds.length)
480
+ continue;
481
+ lines.push(` ${adapter}:`);
482
+ lines.push(" models:");
483
+ for (const model of modelIds) {
484
+ const v = models[model];
485
+ if (v === null)
486
+ lines.push(` ${model}: null`);
487
+ else
488
+ lines.push(annotate(`tiers.${adapter}.models.${model}`, ` ${model}: ${v.tier}`));
489
+ }
490
+ }
491
+ }
492
+ return `${lines.join("\n")}\n`;
493
+ }
494
+ function sortedUnique(xs) {
495
+ return [...new Set(xs)].sort();
496
+ }
497
+ /** Build the repo overlay fragment fleet would write for edits since session start. */
498
+ export function fleetRepoOverlayFromDelta(initial, edited, existingRepo = {}) {
499
+ if (fleetEditableEquals(initial, edited))
500
+ return {};
501
+ const out = structuredClone(existingRepo);
502
+ const routing = { ...out.routing };
503
+ let routingTouched = false;
504
+ const denyChanged = sortedUnique(initial.denyAdapters).join() !== sortedUnique(edited.denyAdapters).join()
505
+ || sortedUnique(initial.denyModels).join() !== sortedUnique(edited.denyModels).join();
506
+ if (denyChanged) {
507
+ routing.deny = {
508
+ adapters: edited.denyAdapters.length ? edited.denyAdapters : null,
509
+ models: edited.denyModels.length ? edited.denyModels : null,
510
+ };
511
+ routingTouched = true;
512
+ }
513
+ const mapDelta = {};
514
+ for (const shape of new Set([...Object.keys(initial.map), ...Object.keys(edited.map)])) {
515
+ if (JSON.stringify(initial.map[shape]) !== JSON.stringify(edited.map[shape]))
516
+ mapDelta[shape] = edited.map[shape];
517
+ }
518
+ if (Object.keys(mapDelta).length) {
519
+ routing.map = { ...routing.map, ...mapDelta };
520
+ routingTouched = true;
521
+ }
522
+ const floorDelta = {};
523
+ for (const shape of new Set([...Object.keys(initial.floors), ...Object.keys(edited.floors)])) {
524
+ if (initial.floors[shape] !== edited.floors[shape])
525
+ floorDelta[shape] = edited.floors[shape];
526
+ }
527
+ if (Object.keys(floorDelta).length) {
528
+ routing.floors = { ...routing.floors, ...floorDelta };
529
+ routingTouched = true;
530
+ }
531
+ if (routingTouched)
532
+ out.routing = routing;
533
+ const tiersOut = {
534
+ ...out.tiers,
535
+ };
536
+ let tiersTouched = false;
537
+ const adapters = new Set([...Object.keys(initial.tiers), ...Object.keys(edited.tiers)]);
538
+ for (const adapter of adapters) {
539
+ const models = new Set([
540
+ ...Object.keys(initial.tiers[adapter] ?? {}),
541
+ ...Object.keys(edited.tiers[adapter] ?? {}),
542
+ ]);
543
+ const modelDelta = {};
544
+ for (const model of models) {
545
+ const a = initial.tiers[adapter]?.[model];
546
+ const b = edited.tiers[adapter]?.[model];
547
+ if (JSON.stringify(a) !== JSON.stringify(b)) {
548
+ modelDelta[model] = b === null || b === undefined ? null : b.tier;
549
+ }
550
+ }
551
+ if (Object.keys(modelDelta).length) {
552
+ tiersOut[adapter] = { models: { ...tiersOut[adapter]?.models, ...modelDelta } };
553
+ tiersTouched = true;
554
+ }
555
+ }
556
+ if (tiersTouched)
557
+ out.tiers = tiersOut;
558
+ return out;
559
+ }
560
+ export function repoOverlayYaml(overlay, provenance = {}) {
561
+ if (!Object.keys(overlay).length)
562
+ return "";
563
+ const fleet = fleetSubset(overlay);
564
+ const fleetBody = serializeFleetOverlay(fleet, provenance);
565
+ const rest = { ...overlay };
566
+ for (const k of FLEET_OVERLAY_KEYS)
567
+ delete rest[k];
568
+ if (!Object.keys(rest).length)
569
+ return fleetBody;
570
+ const head = stringify(rest).trimEnd();
571
+ return fleetBody ? `${head}\n${fleetBody}` : `${head}\n`;
572
+ }
573
+ export function serializeFleetOverlay(overlay, provenance = {}) {
574
+ if (!Object.keys(overlay).length)
575
+ return "";
576
+ const lines = [];
577
+ const today = new Date().toISOString().slice(0, 10);
578
+ const routing = overlay.routing;
579
+ if (routing) {
580
+ lines.push("routing:");
581
+ const deny = routing.deny;
582
+ if (deny) {
583
+ lines.push(" deny:");
584
+ if (deny.adapters)
585
+ lines.push(` adapters: ${stringify(deny.adapters).trim()}`);
586
+ if (deny.models)
587
+ lines.push(` models: ${stringify(deny.models).trim()}`);
588
+ }
589
+ if (routing.map) {
590
+ lines.push(" map:");
591
+ for (const [shape, entry] of Object.entries(routing.map)) {
592
+ lines.push(` ${shape}:`);
593
+ const body = stringify(entry, { indent: 2 }).trim().split("\n");
594
+ lines.push(...body.map((l) => ` ${l}`));
595
+ }
596
+ }
597
+ if (routing.floors)
598
+ lines.push(` floors: ${stringify(routing.floors).trim().replace(/^/gm, " ")}`);
599
+ }
600
+ const tiers = overlay.tiers;
601
+ if (tiers) {
602
+ lines.push("tiers:");
603
+ for (const [adapter, entry] of Object.entries(tiers)) {
604
+ lines.push(` ${adapter}:`);
605
+ if (entry.vendor)
606
+ lines.push(` vendor: ${entry.vendor}`);
607
+ if (entry.channel)
608
+ lines.push(` channel: ${entry.channel}`);
609
+ lines.push(" models:");
610
+ for (const [model, tier] of Object.entries(entry.models ?? {})) {
611
+ if (tier === null)
612
+ lines.push(` ${model}: null`);
613
+ else {
614
+ const note = provenance[adapter]?.[model];
615
+ const suffix = note ? ` # ${note} — fleet ${today}` : "";
616
+ lines.push(` ${model}: ${tier}${suffix}`);
617
+ }
618
+ }
619
+ }
620
+ }
621
+ return `${lines.join("\n")}\n`;
622
+ }
623
+ export function unifiedYamlDiff(before, after, label = "config overlay") {
624
+ const a = before.split("\n");
625
+ const b = after.split("\n");
626
+ if (before === after)
627
+ return "";
628
+ const header = [`--- ${label} (current)`, `+++ ${label} (proposed)`];
629
+ const hunks = [];
630
+ let i = 0;
631
+ let j = 0;
632
+ while (i < a.length || j < b.length) {
633
+ if (i < a.length && j < b.length && a[i] === b[j]) {
634
+ i++;
635
+ j++;
636
+ continue;
637
+ }
638
+ const startI = i;
639
+ const startJ = j;
640
+ while (i < a.length && (j >= b.length || a[i] !== b[j]))
641
+ i++;
642
+ while (j < b.length && (i >= a.length || a[i] !== b[j]))
643
+ j++;
644
+ hunks.push("@@");
645
+ for (let k = startI; k < i; k++)
646
+ hunks.push(`-${a[k]}`);
647
+ for (let k = startJ; k < j; k++)
648
+ hunks.push(`+${b[k]}`);
649
+ }
650
+ return `${header.join("\n")}\n${hunks.join("\n")}\n`;
651
+ }
652
+ export function fleetEditableEquals(a, b) {
653
+ return JSON.stringify(a) === JSON.stringify(b);
654
+ }
@@ -28,6 +28,7 @@ export declare class HerdrDriver implements ExecutorDriver {
28
28
  private glyphFor;
29
29
  private joinGroup;
30
30
  run(slot: Slot, cmd: string): Promise<void>;
31
+ private waitOk;
31
32
  waitOutput(slot: Slot, pattern: string, timeoutMs: number, opts?: {
32
33
  regex?: boolean;
33
34
  }): Promise<boolean>;
@@ -275,15 +275,25 @@ export class HerdrDriver {
275
275
  if (r.code !== 0)
276
276
  throw new Error(`herdr pane run failed: ${r.stderr || r.stdout}`);
277
277
  }
278
+ waitOk(code, stdout) {
279
+ if (code !== 0 || !stdout.trim())
280
+ return code === 0; // herdr's successful waits may be silent
281
+ try {
282
+ return !Object.hasOwn(JSON.parse(stdout), "error");
283
+ }
284
+ catch {
285
+ return false; // a non-empty herdr wait response must be a parseable envelope
286
+ }
287
+ }
278
288
  async waitOutput(slot, pattern, timeoutMs, opts) {
279
289
  const pane = await this.paneId(slot);
280
290
  const r = await this.herdr(`wait output ${shq(pane)} --match ${shq(pattern)}${opts?.regex ? " --regex" : ""} --timeout ${Math.floor(timeoutMs)}`, slot.cwd, timeoutMs + 15_000);
281
- return r.code === 0 && !r.stdout.includes('"error"'); // dead pane: exit 0 + error json (herdr bite)
291
+ return this.waitOk(r.code, r.stdout); // dead pane: exit 0 + top-level error envelope (herdr bite)
282
292
  }
283
293
  async waitAgentStatus(slot, status, timeoutMs) {
284
294
  const pane = await this.paneId(slot);
285
295
  const r = await this.herdr(`wait agent-status ${shq(pane)} --status ${shq(status)} --timeout ${Math.floor(timeoutMs)}`, slot.cwd, timeoutMs + 15_000);
286
- return r.code === 0 && !r.stdout.includes('"error"');
296
+ return this.waitOk(r.code, r.stdout);
287
297
  }
288
298
  async status(slot) {
289
299
  return this.statusByName(slot.name);
@@ -61,14 +61,19 @@ const isTest = (a) => typeof a === "object" && a.oracle === "test";
61
61
  const isJudge = (a) => typeof a === "string" || (typeof a === "object" && a.oracle === "judge");
62
62
  // npm/yarn/pnpm/npx script wrappers need `--` to forward -t to the underlying vitest/jest runner; a bare
63
63
  // runner (vitest/jest) takes -t directly. -t is the shared testNamePattern shorthand both honor.
64
+ // OBS-62: vitest -t treats the name as a regex — escape metachars so a verbatim-titled test matches.
65
+ function escapeRegExp(s) {
66
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
67
+ }
64
68
  // OBS-55: when the base command already contains `--`, append -t after forwarded args — a second `--`
65
69
  // makes vitest treat -t as a positional file filter and the name filter is dropped.
66
70
  export function testFiltered(testCmd, name) {
71
+ const pattern = escapeRegExp(name);
67
72
  const wrapped = /^\s*(npm|yarn|pnpm|npx)\b/.test(testCmd);
68
73
  if (wrapped && /\s--\s/.test(testCmd))
69
- return `${testCmd} -t ${shq(name)}`;
74
+ return `${testCmd} -t ${shq(pattern)}`;
70
75
  const fwd = wrapped ? "-- " : "";
71
- return `${testCmd} ${fwd}-t ${shq(name)}`;
76
+ return `${testCmd} ${fwd}-t ${shq(pattern)}`;
72
77
  }
73
78
  // vitest/jest summary: "Tests N passed | M skipped (T)" — count passed+failed as actually ran.
74
79
  function testsRan(output) {
@@ -1,8 +1,9 @@
1
1
  import type { WorkerResult } from "../adapters/types.js";
2
2
  import type { GateResult } from "./types.js";
3
+ export declare function scopeDiffBase(worktree: string, integrationTip: string): Promise<string>;
3
4
  /** Pure offender split — corpus-replay oracle exercises this exact decision logic. */
4
5
  export declare function dispositionOffenders(offenders: string[], allowDeviations: string[]): {
5
6
  hard: string[];
6
7
  allowed: string[];
7
8
  };
8
- export declare function scopeGate(worktree: string, baseRef: string, files: string[], result: WorkerResult, allowDeviations?: string[]): Promise<GateResult>;
9
+ export declare function scopeGate(worktree: string, integrationTip: string, files: string[], result: WorkerResult, allowDeviations?: string[]): Promise<GateResult>;
@@ -1,5 +1,14 @@
1
1
  import picomatch from "picomatch";
2
- import { shOk } from "../run/git.js";
2
+ import { shGitOk } from "../run/git.js";
3
+ // OBS-61: the live integration tip can advance between attempts (sibling merge) while a resumed
4
+ // worktree stays parented on the old base — merge-base(tip, HEAD) pins the diff to the worktree's
5
+ // creation ancestor so sibling edits cannot leak into scope as false out-of-scope offenders.
6
+ export async function scopeDiffBase(worktree, integrationTip) {
7
+ const head = (await shGitOk("git rev-parse HEAD", worktree)).trim();
8
+ if (head === integrationTip)
9
+ return integrationTip;
10
+ return (await shGitOk(`git merge-base '${integrationTip}' '${head}'`, worktree)).trim();
11
+ }
3
12
  /** Pure offender split — corpus-replay oracle exercises this exact decision logic. */
4
13
  export function dispositionOffenders(offenders, allowDeviations) {
5
14
  const allowedMatch = allowDeviations.length ? picomatch(allowDeviations, { dot: true }) : () => false;
@@ -13,10 +22,11 @@ export function dispositionOffenders(offenders, allowDeviations) {
13
22
  }
14
23
  return { hard, allowed };
15
24
  }
16
- export async function scopeGate(worktree, baseRef, files, result, allowDeviations = []) {
25
+ export async function scopeGate(worktree, integrationTip, files, result, allowDeviations = []) {
17
26
  if (!files.length)
18
27
  return { gate: "scope", pass: true, details: "no file scope declared — unrestricted" };
19
- const changed = (await shOk(`git diff --name-only '${baseRef}..HEAD'`, worktree)).trim().split("\n").filter(Boolean);
28
+ const baseRef = await scopeDiffBase(worktree, integrationTip);
29
+ const changed = (await shGitOk(`git diff --name-only '${baseRef}..HEAD'`, worktree)).trim().split("\n").filter(Boolean);
20
30
  const inScope = picomatch(files, { dot: true }); // byte-identical options to assertWriteScope in src/compile/common.ts
21
31
  const offenders = changed.filter((f) => !inScope(f));
22
32
  if (!offenders.length)
@@ -1,5 +1,5 @@
1
1
  export function scopePrompt(intent, repair) {
2
- return `TICKMARKR-JUDGE
2
+ return `TICKMARKR-SCOPE
3
3
  You are drafting a tickmarkr native spec from an answered intent. Return only the Markdown spec, with no commentary or code fence.
4
4
 
5
5
  The draft must:
@@ -11,6 +11,7 @@ export interface ProfileRow {
11
11
  consults?: number;
12
12
  parkKind?: string;
13
13
  runId?: string;
14
+ taskId?: string;
14
15
  quotaFailover?: true;
15
16
  overrun?: true;
16
17
  }
@@ -23,6 +24,7 @@ export interface ProfileCell {
23
24
  doneMedianMs?: number;
24
25
  nRaw?: number;
25
26
  overruns?: number;
27
+ discounted?: number;
26
28
  }
27
29
  export interface RoutingProfile {
28
30
  cells: Map<string, ProfileCell>;
@@ -38,10 +40,20 @@ export declare const EXPLORE_CAP = 5;
38
40
  export declare const HALF_LIFE_RUNS = 5;
39
41
  export declare const DECAY_CAP = 30;
40
42
  export declare function decayWeight(age: number, halfLife?: number): number;
41
- export declare function explorationBonus(cell: ProfileCell | undefined): number;
43
+ export declare const HYGIENE_WEIGHTS: readonly [0, 0.5, 1];
44
+ export type HygieneWeight = (typeof HYGIENE_WEIGHTS)[number];
45
+ export interface ProfileDiscount {
46
+ runId: string;
47
+ taskId?: string;
48
+ weight: 0 | 0.5;
49
+ reason: string;
50
+ }
51
+ export declare function resolveHygieneWeight(row: ProfileRow, discounts?: readonly ProfileDiscount[]): HygieneWeight;
52
+ export declare function explorationBonus(cell: ProfileCell | undefined, cap?: number): number;
42
53
  export declare function classify(r: ProfileRow): 1 | 0.5 | 0 | null;
43
54
  export declare function buildProfile(rows: ProfileRow[], opts?: {
44
55
  halfLifeRuns?: number;
56
+ discounts?: readonly ProfileDiscount[];
45
57
  }): RoutingProfile;
46
58
  export declare function cellOf(profile: RoutingProfile | undefined, shape: string, chKey: string, channel: string): ProfileCell | undefined;
47
59
  export declare function cellsOf(profile: RoutingProfile): Generator<{
@@ -58,8 +70,20 @@ export interface CellSummary {
58
70
  quotaHits: number;
59
71
  cold: boolean;
60
72
  exploreRemaining: number;
73
+ discounted: number;
61
74
  }
62
75
  export declare function cellSummary(cell: ProfileCell): CellSummary;
76
+ export interface LearnedScoreTerms {
77
+ quality: number;
78
+ perf: number;
79
+ avail: number;
80
+ overrun: number;
81
+ }
82
+ export declare function learnedScoreTerms(profile: RoutingProfile | undefined, shape: string, chKey: string, channel: string, opts?: {
83
+ availWeight?: number;
84
+ slaMinutes?: number;
85
+ }): LearnedScoreTerms;
63
86
  export declare function learnedScore(profile: RoutingProfile | undefined, shape: string, chKey: string, channel: string, opts?: {
64
87
  availWeight?: number;
88
+ slaMinutes?: number;
65
89
  }): number;
@@ -31,6 +31,25 @@ export const DECAY_CAP = 30;
31
31
  export function decayWeight(age, halfLife = HALF_LIFE_RUNS) {
32
32
  return 2 ** -Math.min(Math.floor(age / halfLife), DECAY_CAP);
33
33
  }
34
+ // v1.46 T5 evidence hygiene — operator-filed discounts on poisoned runs/tasks. h ∈ {0, 0.5, 1} is dyadic
35
+ // so h·w (w a power of two) and every partial sum stay exact; magnitude headroom loses one bit (2^-32 floor).
36
+ export const HYGIENE_WEIGHTS = [0, 0.5, 1];
37
+ // Resolve h for one row: run-level marks (no taskId) apply to every row in the run; task-level marks are
38
+ // selective. Multiple marks ⇒ minimum weight (most aggressive discount wins).
39
+ export function resolveHygieneWeight(row, discounts = []) {
40
+ if (!discounts.length || row.runId === undefined)
41
+ return 1;
42
+ let h = 1;
43
+ for (const d of discounts) {
44
+ if (d.runId !== row.runId)
45
+ continue;
46
+ if (d.taskId !== undefined && d.taskId !== row.taskId)
47
+ continue;
48
+ if (d.weight < h)
49
+ h = d.weight;
50
+ }
51
+ return h;
52
+ }
34
53
  // Phase 14 exploration bonus: an under-observed channel keeps gathering evidence so early bad luck can't
35
54
  // starve it permanently (EXP-01). Total by construction — dispatches is a finite non-negative integer counter,
36
55
  // the denominator is a compile-time positive constant; no ln/sqrt/data-dependent divide, so none of the
@@ -40,10 +59,11 @@ export function decayWeight(age, halfLife = HALF_LIFE_RUNS) {
40
59
  // yielded no quality obs (quota parks) has spent its budget — stop probing it, don't probe it forever.
41
60
  // ponytail: rank-decay ships in v1.8 (buildProfile below); remaining ceiling = doneMedianMs staleness
42
61
  // (perf term is undecayed) and per-cell EWMA as the finer upgrade path.
43
- export function explorationBonus(cell) {
62
+ // ROUTE-15 PARAM-SHAPE: optional cap threads from routing.explore.cap; undefined ⇒ module default (byte-identical).
63
+ export function explorationBonus(cell, cap = EXPLORE_CAP) {
44
64
  if (!cell)
45
65
  return 0;
46
- return Math.max(0, 1 - cell.dispatches / EXPLORE_CAP); // ∈ [0,1]; exactly 0 once dispatches ≥ cap
66
+ return Math.max(0, 1 - cell.dispatches / cap); // ∈ [0,1]; exactly 0 once dispatches ≥ cap
47
67
  }
48
68
  const QUALITY_FAIL_PARKS = new Set(["ladder-exhausted", "attempt-cap", "gate-fail"]);
49
69
  // Quality observation per row: 1 clean, 0.5 degraded, 0 verified failure, null excluded/unobserved.
@@ -107,8 +127,12 @@ export function buildProfile(rows, opts = {}) {
107
127
  // in IEEE-754 ⇒ the fold stays order-insensitive (v1.7's invariant EXTENDED, not abandoned). Magnitude
108
128
  // headroom: weights are multiples of 2^-31, so exactness holds while row-count ≪ 2^21. nRaw is the
109
129
  // integer undecayed observation count (Phase 28) — never enters the score arithmetic.
130
+ // SINGLE evidence-fold site — hygiene h multiplies quality evidence ONLY (n, qSum), never dispatches/quotaHits/overruns.
110
131
  if (q !== null) {
111
- const w = decayWeight(ageOf(r.runId), halfLife);
132
+ const h = resolveHygieneWeight(r, opts.discounts);
133
+ const w = decayWeight(ageOf(r.runId), halfLife) * h;
134
+ if (h < 1)
135
+ c.discounted = (c.discounted ?? 0) + 1;
112
136
  c.n += w;
113
137
  c.qSum += q * w;
114
138
  c.nRaw = (c.nRaw ?? 0) + 1;
@@ -154,18 +178,16 @@ export function cellSummary(cell) {
154
178
  quotaHits: cell.quotaHits,
155
179
  cold: cell.n < MIN_SAMPLES,
156
180
  exploreRemaining: Math.max(0, EXPLORE_CAP - cell.dispatches),
181
+ discounted: cell.discounted ?? 0,
157
182
  };
158
183
  }
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 = {}) {
184
+ // Per-term decomposition of learnedScore the single arithmetic source for profile --explain.
185
+ export function learnedScoreTerms(profile, shape, chKey, channel, opts = {}) {
165
186
  const availWeight = opts.availWeight ?? AVAIL_WEIGHT;
187
+ const refMs = opts.slaMinutes !== undefined ? opts.slaMinutes * 60_000 : REF_MS;
166
188
  const cell = cellOf(profile, shape, chKey, channel);
167
189
  if (!cell)
168
- return NEUTRAL; // unknown channel exactly neutral (empty-profile / cold-start leg, ROUTE-07)
190
+ return { quality: NEUTRAL, perf: NEUTRAL, avail: NEUTRAL, overrun: NEUTRAL };
169
191
  // Cold-start proof (ROUTE-07): n ≤ nRaw ≤ dispatches and doneCount ≤ dispatches by construction, so a
170
192
  // thin-dispatch cell (dispatches < MIN_SAMPLES) gates ALL THREE terms to literal +0 ⇒ exactly NEUTRAL.
171
193
  // Each term is gated INDEPENDENTLY (no early-exit) so a dispatch-warm/quality-cold HIGH-throttle cell can
@@ -178,7 +200,7 @@ export function learnedScore(profile, shape, chKey, channel, opts = {}) {
178
200
  // 0 — a silent ROUTE-07 break. With it, every quotaHits=0 cell is byte-identical to pre-ROUTE-12.
179
201
  const perf = cell.n < MIN_SAMPLES || cell.doneMedianMs === undefined || cell.doneCount < MIN_SAMPLES
180
202
  ? 0
181
- : PERF_WEIGHT * (REF_MS / (REF_MS + cell.doneMedianMs) - 0.5);
203
+ : PERF_WEIGHT * (refMs / (refMs + cell.doneMedianMs) - 0.5);
182
204
  // PENALTY-ONLY availability (ROUTE-12): a throttling channel is less available — deprioritize it without ever
183
205
  // treating quota as a quality signal, ejecting it, or predicting quota. quotaHits=0 ⇒ avail 0 (no-throttle
184
206
  // byte-identity). Gated on dispatches (≥ MIN_SAMPLES > 0 ⇒ positive denominator, no divide-by-zero) INDEPENDENTLY
@@ -192,6 +214,18 @@ export function learnedScore(profile, shape, chKey, channel, opts = {}) {
192
214
  // fact as a quality signal, ejecting it, or predicting it. overruns=0 ⇒ overrunPen 0 (overrun-free byte-identity).
193
215
  // Gated on dispatches (≥ MIN_SAMPLES > 0 ⇒ positive denominator, no divide-by-zero) INDEPENDENTLY of n, mirroring
194
216
  // 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
217
+ const overrun = cell.dispatches < MIN_SAMPLES ? 0 : -OVERRUN_WEIGHT * ((cell.overruns ?? 0) / cell.dispatches); // ∈ [−OVERRUN_WEIGHT, 0]
218
+ return { quality, perf, avail, overrun };
219
+ }
220
+ // Total by construction: every miss returns the explicit NEUTRAL constant; every denominator is a
221
+ // compile-time-positive constant sum; no Record indexing by row strings (the TIER_RANK[typo] scar).
222
+ // Score 0 defers to the static sort keys, which already encode the benchmark-dated tier seeds — a
223
+ // per-channel numeric prior would give differing n=0 scores and break ROUTE-07's byte-identical cold
224
+ // start. Shrinking toward 0 IS shrinking toward the static prior.
225
+ export function learnedScore(profile, shape, chKey, channel, opts = {}) {
226
+ const cell = cellOf(profile, shape, chKey, channel);
227
+ if (!cell)
228
+ return NEUTRAL; // unknown channel ⇒ exactly neutral (empty-profile / cold-start leg, ROUTE-07)
229
+ const t = learnedScoreTerms(profile, shape, chKey, channel, opts);
230
+ return t.quality + t.perf + t.avail + t.overrun; // score ∈ (−0.625, +0.525), finite for every input
197
231
  }
@@ -1,5 +1,5 @@
1
1
  import { type Assignment, type BillingChannel } from "../adapters/types.js";
2
- import { type TickmarkrConfig } from "../config/config.js";
2
+ import { type TickmarkrConfig, type Tier } from "../config/config.js";
3
3
  import type { Task } from "../graph/schema.js";
4
4
  import { type RoutingProfile } from "./profile.js";
5
5
  export type LadderStep = "retry" | "escalate" | "consult" | "human";
@@ -26,9 +26,16 @@ export interface RoutingPreferContext {
26
26
  doctorFresh: boolean;
27
27
  overlayPreferShapes: ReadonlySet<string>;
28
28
  }
29
+ export interface ExploreContext {
30
+ noExplore?: boolean;
31
+ quality?: boolean;
32
+ }
33
+ export declare const NO_EXPLORE_ENV = "TICKMARKR_NO_EXPLORE";
34
+ export declare const QUALITY_ENV = "TICKMARKR_QUALITY";
35
+ export declare const raiseTier: (tier: Tier) => Tier;
29
36
  export declare class RoutingError extends Error {
30
37
  constructor(msg: string);
31
38
  }
32
39
  export declare function marginalCostRank(c: BillingChannel): number;
33
- export declare function route(task: Task, cfg: TickmarkrConfig, channels: BillingChannel[], profile?: RoutingProfile, preferCtx?: RoutingPreferContext): Route;
34
- export declare function nextChannel(current: Assignment, task: Task, cfg: TickmarkrConfig, channels: BillingChannel[], tried: string[], profile?: RoutingProfile): Assignment | null;
40
+ export declare function route(task: Task, cfg: TickmarkrConfig, channels: BillingChannel[], profile?: RoutingProfile, preferCtx?: RoutingPreferContext, exclude?: ReadonlySet<string>, exploreCtx?: ExploreContext): Route;
41
+ export declare function nextChannel(current: Assignment, task: Task, cfg: TickmarkrConfig, channels: BillingChannel[], tried: string[], profile?: RoutingProfile, exclude?: ReadonlySet<string>): Assignment | null;