@event4u/agent-config 14.13.0 → 14.14.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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +116 -387
- package/dist/agent-src/commands/analyze/inbox.md +36 -0
- package/dist/agent-src/rules/code-comment-discipline.md +6 -0
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +6 -6
- package/dist/agent-src/scripts/guarded_baseline.ts +22 -7
- package/dist/agent-src/scripts/stubs_due.ts +118 -2
- package/dist/agent-src/scripts/update_roadmap_progress.ts +8 -8
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/cmd_conformance.js +38 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +4 -4
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +1438 -711
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +112 -17
- package/docs/guidelines/agent-infra/guarded-baseline.md +1 -1
- package/docs/guidelines/code-clarity.md +18 -0
- package/package.json +1 -1
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +6 -6
- package/src/agent-src/scripts/guarded_baseline.ts +22 -7
- package/src/agent-src/scripts/stubs_due.ts +118 -2
- package/src/agent-src/scripts/update_roadmap_progress.ts +8 -8
- package/src/config/estate-count-budget.json +20 -1
- package/src/config/gate-coverage.yml +150 -0
- package/src/config/gate-violation-baselines.json +8 -3
- package/src/config/preamble-payload-budget.json +19 -11
- package/src/config/rule-stub-ceilings.json +14 -7
- package/src/scripts/_dispatch.bash +0 -4
- package/src/scripts/_lib/activation_ladder.ts +47 -3
- package/src/scripts/_lib/activation_receipt_producer.ts +341 -0
- package/src/scripts/_lib/adr_frontmatter.ts +26 -0
- package/src/scripts/_lib/candidate_pair_delta.ts +349 -0
- package/src/scripts/_lib/carrier_status.ts +118 -0
- package/src/scripts/_lib/cascade_stage_enumeration.ts +92 -0
- package/src/scripts/_lib/corpus_manifest.ts +726 -0
- package/src/scripts/_lib/delivery_arm_experiment.ts +18 -2
- package/src/scripts/_lib/estate_carrier_cases.ts +72 -0
- package/src/scripts/_lib/evaluation_cascade.ts +452 -0
- package/src/scripts/_lib/evolution_roi.ts +562 -0
- package/src/scripts/_lib/harness_evolution_guards.ts +10 -0
- package/src/scripts/_lib/lexical_shortlist.ts +215 -0
- package/src/scripts/_lib/llm_candidate_proposer.ts +461 -0
- package/src/scripts/_lib/llm_proposer_transport.ts +153 -0
- package/src/scripts/_lib/pathology_archive.ts +393 -0
- package/src/scripts/_lib/promotion_capability.ts +315 -0
- package/src/scripts/_lib/promotion_evidence.ts +414 -0
- package/src/scripts/_lib/promotion_review.ts +294 -0
- package/src/scripts/_lib/prompt_shape.ts +92 -0
- package/src/scripts/_lib/release_highlights.ts +70 -1
- package/src/scripts/_lib/release_material.ts +125 -2
- package/src/scripts/_lib/routing_corpus.ts +307 -0
- package/src/scripts/_lib/routing_index_input.ts +104 -0
- package/src/scripts/_lib/rule_injection.ts +22 -1
- package/src/scripts/_lib/semantic_noop.ts +167 -0
- package/src/scripts/activation_receipt.ts +258 -0
- package/src/scripts/ai_council/cross_exam.ts +128 -0
- package/src/scripts/ai_council/explain_route.ts +157 -0
- package/src/scripts/ai_council/information_gain.ts +293 -0
- package/src/scripts/ai_council/leakage_corpus.ts +385 -0
- package/src/scripts/ai_council/leakage_patterns.ts +453 -0
- package/src/scripts/ai_council/minority_retention.ts +171 -0
- package/src/scripts/ai_council/recouncil_savings.ts +299 -0
- package/src/scripts/ai_council/replay_route.ts +223 -0
- package/src/scripts/ai_council/reviewer_assignment.ts +279 -0
- package/src/scripts/ai_council/routing_training_row.ts +197 -0
- package/src/scripts/ai_council/synthesis_strategy.ts +159 -0
- package/src/scripts/check_adr_frontmatter.ts +4 -9
- package/src/scripts/check_estate_count.ts +205 -24
- package/src/scripts/check_release_highlights.ts +96 -13
- package/src/scripts/check_roadmap_trackable.ts +17 -2
- package/src/scripts/corpus_manifest.ts +235 -0
- package/src/scripts/evolution_lab.ts +181 -8
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +28 -6
- package/src/scripts/hooks/comment_discipline_hook.ts +146 -0
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +9 -1
- package/src/scripts/language_mirror_hook.ts +14 -2
- package/src/scripts/lint_carrier_integrity.ts +820 -0
- package/src/scripts/lint_code_comments.ts +643 -0
- package/src/scripts/lint_plan_risk_register.ts +39 -19
- package/src/scripts/lint_promotion_paths.ts +779 -0
- package/src/scripts/llm_propose.ts +256 -0
- package/src/scripts/measure_delivery_sets.ts +361 -0
- package/src/scripts/measure_routing_signal.ts +307 -0
- package/src/scripts/model_rule_injection.ts +19 -3
- package/src/scripts/probe_council_retention.ts +495 -0
- package/src/scripts/release.ts +21 -37
- package/src/scripts/release_drill.ts +143 -0
- package/src/scripts/release_env.ts +35 -0
- package/src/scripts/release_publication.ts +84 -0
|
@@ -104,6 +104,17 @@ export interface ArmExperimentInput {
|
|
|
104
104
|
readonly standing: Readonly<Record<ArmName, number>>;
|
|
105
105
|
/** Exact-BPE tokenizer, injected so this module imports none. */
|
|
106
106
|
readonly tokensOf: (text: string) => number;
|
|
107
|
+
/**
|
|
108
|
+
* STEP 6.3 — optional per-prompt lexical shortlist, injected so this module
|
|
109
|
+
* builds no index of its own.
|
|
110
|
+
*
|
|
111
|
+
* Absent (the default) the delivery arm is byte-for-byte the arm 6.1
|
|
112
|
+
* measured, which is why its recorded figures stay reproducible. Present,
|
|
113
|
+
* it reaches `selectForInjection` as a TIE-BREAK under the matcher's score —
|
|
114
|
+
* it can change which matched bodies survive a binding cap and can change
|
|
115
|
+
* nothing else, so `delivered` can only move through `capDropped`.
|
|
116
|
+
*/
|
|
117
|
+
readonly shortlist?: ((prompt: string) => readonly string[]) | null;
|
|
107
118
|
}
|
|
108
119
|
|
|
109
120
|
/**
|
|
@@ -143,7 +154,7 @@ function hasBody(repoRoot: string, id: string): boolean {
|
|
|
143
154
|
* a different case set than another.
|
|
144
155
|
*/
|
|
145
156
|
export function runArmExperiment(input: ArmExperimentInput): ArmRow[] {
|
|
146
|
-
const { repoRoot, router, cases, capBytes, standing, tokensOf } = input;
|
|
157
|
+
const { repoRoot, router, cases, capBytes, standing, tokensOf, shortlist } = input;
|
|
147
158
|
const thinStanding = thinStandingIds(router);
|
|
148
159
|
|
|
149
160
|
const delivered: Record<ArmName, number> = { 'eager-all': 0, thin: 0, delivery: 0 };
|
|
@@ -158,7 +169,12 @@ export function runArmExperiment(input: ArmExperimentInput): ArmRow[] {
|
|
|
158
169
|
const inThin = thinStanding.has(c.rule) && inEager;
|
|
159
170
|
|
|
160
171
|
const matches = matchTierRules(router, c.prompt, c.openFiles);
|
|
161
|
-
const sel = selectForInjection(
|
|
172
|
+
const sel = selectForInjection(
|
|
173
|
+
repoRoot,
|
|
174
|
+
matches,
|
|
175
|
+
capBytes,
|
|
176
|
+
shortlist ? shortlist(c.prompt) : null,
|
|
177
|
+
);
|
|
162
178
|
const injectedIds = new Set(sel.selected.map((m) => m.id));
|
|
163
179
|
const inDelivery = inThin || injectedIds.has(c.rule);
|
|
164
180
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three `check_estate_count` self-test cases that exercise `status: carrier`.
|
|
3
|
+
*
|
|
4
|
+
* They live outside that gate because it sits at the 1,500-line source ceiling
|
|
5
|
+
* `check_source_size_budget` enforces, and because the doctrine that gate states
|
|
6
|
+
* for an oversized file is a split rather than a raised baseline. The reasoning
|
|
7
|
+
* behind the fixture shapes is in `carrier_status.ts` § Fixture design.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { SelfTestCase } from './gate_self_test.js';
|
|
11
|
+
|
|
12
|
+
/** What the caller must supply: its own fixture harness and roadmap bodies. */
|
|
13
|
+
export interface EstateCaseDeps {
|
|
14
|
+
fixture: (opts: {
|
|
15
|
+
roadmaps: number;
|
|
16
|
+
base?: string;
|
|
17
|
+
before?: (dir: string) => void;
|
|
18
|
+
after: (dir: string) => void;
|
|
19
|
+
}) => number;
|
|
20
|
+
write: (dir: string, rel: string, body: string) => void;
|
|
21
|
+
remove: (dir: string, rel: string) => void;
|
|
22
|
+
roadmap: (name: string, extra?: string) => string;
|
|
23
|
+
CARRIER: string;
|
|
24
|
+
DRAFT: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function carrierEstateCases(d: EstateCaseDeps): SelfTestCase[] {
|
|
28
|
+
const { fixture, write, remove, roadmap, CARRIER, DRAFT } = d;
|
|
29
|
+
return [
|
|
30
|
+
{
|
|
31
|
+
name: 'deleting a carrier does not offset a new draft → reject',
|
|
32
|
+
expect: 'reject',
|
|
33
|
+
run: () =>
|
|
34
|
+
fixture({
|
|
35
|
+
roadmaps: 3,
|
|
36
|
+
base: 'main',
|
|
37
|
+
before: (dir) => write(dir, 'agents/roadmaps/road-to-carrier.md', CARRIER),
|
|
38
|
+
after: (dir) => {
|
|
39
|
+
remove(dir, 'agents/roadmaps/road-to-carrier.md');
|
|
40
|
+
write(dir, 'agents/roadmaps/road-to-new.md', DRAFT);
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
// The control that proves the case above tests the STATUS rather
|
|
46
|
+
// than the deletion: only the removed file's status differs.
|
|
47
|
+
name: 'deleting an ordinary roadmap still offsets a new draft → accept',
|
|
48
|
+
expect: 'accept',
|
|
49
|
+
run: () =>
|
|
50
|
+
fixture({
|
|
51
|
+
roadmaps: 3,
|
|
52
|
+
base: 'main',
|
|
53
|
+
before: (dir) => write(dir, 'agents/roadmaps/road-to-spare.md', roadmap('S')),
|
|
54
|
+
after: (dir) => {
|
|
55
|
+
remove(dir, 'agents/roadmaps/road-to-spare.md');
|
|
56
|
+
write(dir, 'agents/roadmaps/road-to-new.md', DRAFT);
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'flipping a carrier back to an ordinary roadmap costs nothing → accept',
|
|
62
|
+
expect: 'accept',
|
|
63
|
+
run: () =>
|
|
64
|
+
fixture({
|
|
65
|
+
roadmaps: 3,
|
|
66
|
+
base: 'main',
|
|
67
|
+
before: (dir) => write(dir, 'agents/roadmaps/road-to-2.md', CARRIER),
|
|
68
|
+
after: (dir) => write(dir, 'agents/roadmaps/road-to-2.md', roadmap('R2')),
|
|
69
|
+
}),
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
}
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The deterministic evaluation cascade — cheap to expensive, abort on the first
|
|
3
|
+
* hard failure.
|
|
4
|
+
*
|
|
5
|
+
* `road-to-governed-harness-evolution` step 4.1, built to the AI council's
|
|
6
|
+
* **Option B** of 2026-08-31 and NOT to Option A. The distinction is the whole
|
|
7
|
+
* design of this file, so it is recorded here rather than only in the roadmap.
|
|
8
|
+
*
|
|
9
|
+
* ## Why this is a PREFIX and not the twelve-stage cascade
|
|
10
|
+
*
|
|
11
|
+
* Step 4.1 names a twelve-stage form whose stages include activation/delivery
|
|
12
|
+
* and adherence. Those stages classify a candidate by observing how the harness
|
|
13
|
+
* BEHAVED, and Phase 1's step 1.1 fixes what that classification rests on: *"a
|
|
14
|
+
* deliberately failing trigger eval is classifiable as content vs activation vs
|
|
15
|
+
* adherence **from the recorded receipt alone**"*. There is no receipt producer.
|
|
16
|
+
* Until there is, a stage that assigned `activation` from a deterministic proxy
|
|
17
|
+
* would be manufacturing evidence — a holdout leak is not an observation that
|
|
18
|
+
* activation failed, and an `underpowered` result asserts insufficient evidence
|
|
19
|
+
* rather than a cause.
|
|
20
|
+
*
|
|
21
|
+
* So this module implements exactly the stages whose evidence the process
|
|
22
|
+
* produces ITSELF and can stand behind:
|
|
23
|
+
*
|
|
24
|
+
* | # | Stage | Cost | Family on failure |
|
|
25
|
+
* |---|---|---|---|
|
|
26
|
+
* | 1 | schema validity | zero model calls | `content` |
|
|
27
|
+
* | 2 | path ownership *(unreachable today — see the stage)* | zero model calls | `content` |
|
|
28
|
+
* | 3 | holdout disclosure | zero model calls | `unknown` |
|
|
29
|
+
* | 4 | budget | zero model calls | `unknown` |
|
|
30
|
+
* | 5 | near-duplicate screen | zero model calls | `content` |
|
|
31
|
+
* | 6 | metric vector + verdict| zero model calls | `unknown` |
|
|
32
|
+
*
|
|
33
|
+
* (Numbered as the prefix. In the full twelve-stage enumeration the receipt
|
|
34
|
+
* stages take slots 6-11 and the verdict is slot 12 — see
|
|
35
|
+
* `_lib/cascade_stage_enumeration.ts`.)
|
|
36
|
+
*
|
|
37
|
+
* **Every stage here is free.** That is not an accident of the current stage
|
|
38
|
+
* set — it is why these six are the ones that can ship without receipts, and it
|
|
39
|
+
* makes the step's "a candidate failing the cheapest stage consumes no model
|
|
40
|
+
* call" trivially true rather than merely asserted.
|
|
41
|
+
*
|
|
42
|
+
* **No stage is SOFT.** A condition whose failure does not block is a
|
|
43
|
+
* diagnostic, not a cascade gate — so every stage here either passes or aborts
|
|
44
|
+
* the cascade, and there is no third "warn" outcome a caller could ignore.
|
|
45
|
+
*
|
|
46
|
+
* ## The families, and the one this prefix may NOT assign
|
|
47
|
+
*
|
|
48
|
+
* `content | activation | adherence | unknown` are Phase 1's, and no fifth is
|
|
49
|
+
* invented. This prefix assigns `content` and `unknown` only. It NEVER assigns
|
|
50
|
+
* `activation` or `adherence`, because those are the receipt-bearing families
|
|
51
|
+
* and nothing in the prefix has a receipt. `unknown` is the honest family for a
|
|
52
|
+
* stage that establishes a candidate is unusable without establishing why in
|
|
53
|
+
* behavioural terms — it is a real classification, not a fallback.
|
|
54
|
+
*
|
|
55
|
+
* ## AMENDED 2026-09-01 — the prefix is no longer the whole stage list
|
|
56
|
+
*
|
|
57
|
+
* `road-to-governed-evidence-production` step 1.1 built the missing piece: an
|
|
58
|
+
* independent, append-only receipt producer
|
|
59
|
+
* (`_lib/activation_receipt_producer.ts`). So `runCascade` now accepts an
|
|
60
|
+
* optional {@link CascadeInput.receipt} and, when one is supplied, runs the six
|
|
61
|
+
* receipt-bearing stages between the peer screen and the measurement stage.
|
|
62
|
+
*
|
|
63
|
+
* **`PREFIX_ASSIGNABLE_FAMILIES` was NOT widened, and that is the point.** The
|
|
64
|
+
* exclusion is a property of the deterministic prefix and stays exactly as
|
|
65
|
+
* strict as it was: no prefix stage may assign `activation` or `adherence`. What
|
|
66
|
+
* changed is that the prefix is one half of the stage list rather than all of
|
|
67
|
+
* it. The other half's permitted families are
|
|
68
|
+
* {@link RECEIPT_ASSIGNABLE_FAMILIES}, and a receipt stage reaches them only
|
|
69
|
+
* from a receipt — never from any property of the candidate.
|
|
70
|
+
*
|
|
71
|
+
* The trust boundary and the evidence-cost claims this rests on are stated in
|
|
72
|
+
* `docs/contracts/activation-receipt-trust-boundary.md` and cited, not restated,
|
|
73
|
+
* here: TB-1 fixes the import direction (this module imports the ladder; the
|
|
74
|
+
* ladder imports nothing from here), and EC-2 fixes the stage placement.
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
import {
|
|
78
|
+
assertMutationPathsOwned,
|
|
79
|
+
parseCandidateRecord,
|
|
80
|
+
PathOwnershipError,
|
|
81
|
+
CandidateSchemaError,
|
|
82
|
+
type CandidateRecord,
|
|
83
|
+
} from './candidate_record.js';
|
|
84
|
+
import {
|
|
85
|
+
assertWithinBudget,
|
|
86
|
+
BudgetExceededError,
|
|
87
|
+
diversityCollapsed,
|
|
88
|
+
type RunBudget,
|
|
89
|
+
type RunPlan,
|
|
90
|
+
} from './harness_evolution_guards.js';
|
|
91
|
+
import {
|
|
92
|
+
buildVector,
|
|
93
|
+
promotionVerdict,
|
|
94
|
+
type MetricRow,
|
|
95
|
+
type MetricVector,
|
|
96
|
+
type PromotionVerdict,
|
|
97
|
+
} from './evaluation_vector.js';
|
|
98
|
+
import {
|
|
99
|
+
LADDER,
|
|
100
|
+
RECEIPT_STAGES,
|
|
101
|
+
firstStall,
|
|
102
|
+
type ActivationReceipt,
|
|
103
|
+
type ReceiptStage,
|
|
104
|
+
} from './activation_ladder.js';
|
|
105
|
+
|
|
106
|
+
/** Phase 1's families. No fifth is invented here. */
|
|
107
|
+
export const FAILURE_FAMILIES = ['content', 'activation', 'adherence', 'unknown'] as const;
|
|
108
|
+
export type FailureFamily = (typeof FAILURE_FAMILIES)[number];
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The families this prefix is permitted to assign.
|
|
112
|
+
*
|
|
113
|
+
* `activation` and `adherence` are deliberately absent: assigning either from a
|
|
114
|
+
* deterministic proxy is the evidence-manufacturing the council refused.
|
|
115
|
+
*/
|
|
116
|
+
export const PREFIX_ASSIGNABLE_FAMILIES: readonly FailureFamily[] = ['content', 'unknown'];
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The families the RECEIPT-BEARING stages may assign.
|
|
120
|
+
*
|
|
121
|
+
* **DERIVED from {@link LADDER}, not written out** — and the first hand-written
|
|
122
|
+
* version of this constant was WRONG, which is why it is derived now. It read
|
|
123
|
+
* `['activation','adherence','unknown']` on the assumption that the receipt half
|
|
124
|
+
* was the complement of the prefix half. It is not: the `eligible` rung's family
|
|
125
|
+
* is `content`, so a receipt stage can legitimately assign `content` too, and
|
|
126
|
+
* the test asserting stage-versus-permission agreement caught it on its first
|
|
127
|
+
* run. A permission table maintained by hand beside the table it describes is a
|
|
128
|
+
* second source of truth for the same fact.
|
|
129
|
+
*
|
|
130
|
+
* `unknown` is added because a receipt stage reaches it by a route the ladder's
|
|
131
|
+
* own family column does not carry: a rung that was never observed. That is a
|
|
132
|
+
* property of the WALK ({@link firstStall} short-circuits on `unknown`), not of
|
|
133
|
+
* any rung, so it cannot be derived from `LADDER` and is named here instead.
|
|
134
|
+
*
|
|
135
|
+
* **What did NOT change is the constant that matters.**
|
|
136
|
+
* {@link PREFIX_ASSIGNABLE_FAMILIES} is untouched: no deterministic stage may
|
|
137
|
+
* assign `activation` or `adherence`. The fix for step 4.1's open conjunct was
|
|
138
|
+
* never to widen that exclusion — it was to stop the prefix being the whole
|
|
139
|
+
* stage list.
|
|
140
|
+
*/
|
|
141
|
+
export const RECEIPT_ASSIGNABLE_FAMILIES: readonly FailureFamily[] = [
|
|
142
|
+
...new Set<FailureFamily>([...LADDER.map((s) => s.family), 'unknown']),
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
export const CASCADE_STAGES = [
|
|
146
|
+
'schema-validity',
|
|
147
|
+
'path-ownership',
|
|
148
|
+
'holdout-disclosure',
|
|
149
|
+
'budget',
|
|
150
|
+
'near-duplicate',
|
|
151
|
+
'metric-verdict',
|
|
152
|
+
] as const;
|
|
153
|
+
export type CascadeStage = (typeof CASCADE_STAGES)[number];
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Any stage the cascade can run — a deterministic prefix stage, or one of the
|
|
157
|
+
* six receipt-bearing stages contributed by the activation ladder.
|
|
158
|
+
*
|
|
159
|
+
* The two sets are kept as separate types rather than merged into one enum
|
|
160
|
+
* because their ASSIGNABLE FAMILIES differ, and that difference is the whole
|
|
161
|
+
* trust boundary: a prefix stage may never assign `activation` or `adherence`,
|
|
162
|
+
* and a receipt stage may never assign `content`. One flat enum would make both
|
|
163
|
+
* halves look interchangeable at every call site.
|
|
164
|
+
*/
|
|
165
|
+
export type StageId = CascadeStage | ReceiptStage;
|
|
166
|
+
|
|
167
|
+
/** The cheapest stage. Its failure must cost nothing. */
|
|
168
|
+
export const CHEAPEST_STAGE: CascadeStage = 'schema-validity';
|
|
169
|
+
|
|
170
|
+
const STAGE_FAMILY: Readonly<Record<CascadeStage, FailureFamily>> = {
|
|
171
|
+
'schema-validity': 'content',
|
|
172
|
+
'path-ownership': 'content',
|
|
173
|
+
'holdout-disclosure': 'unknown',
|
|
174
|
+
budget: 'unknown',
|
|
175
|
+
'near-duplicate': 'content',
|
|
176
|
+
'metric-verdict': 'unknown',
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The family a stall AT this stage belongs to. Total over {@link StageId}.
|
|
181
|
+
*
|
|
182
|
+
* The prefix half is this module's table; the receipt half is read from
|
|
183
|
+
* {@link LADDER}, which owns the rung→family mapping. Copying six rows into
|
|
184
|
+
* `STAGE_FAMILY` would be a second place to change when a rung's family changes,
|
|
185
|
+
* and the two would disagree silently.
|
|
186
|
+
*/
|
|
187
|
+
export function familyForStage(stage: StageId): FailureFamily {
|
|
188
|
+
const prefix = STAGE_FAMILY[stage as CascadeStage];
|
|
189
|
+
if (prefix !== undefined) return prefix;
|
|
190
|
+
const i = RECEIPT_STAGES.indexOf(stage as ReceiptStage);
|
|
191
|
+
// Unreachable through the public API: `StageId` is the union of the two
|
|
192
|
+
// sets, so a stage that is in neither table cannot be constructed without a
|
|
193
|
+
// cast. `unknown` is the honest fallback rather than a throw — a classifier
|
|
194
|
+
// that crashes on an unrecognised stage turns a labelling gap into a lost run.
|
|
195
|
+
return i >= 0 ? (LADDER[i] as { family: FailureFamily }).family : 'unknown';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface CascadePass {
|
|
199
|
+
readonly outcome: 'pass';
|
|
200
|
+
readonly candidate_id: string;
|
|
201
|
+
readonly stages_run: readonly StageId[];
|
|
202
|
+
readonly model_calls: 0;
|
|
203
|
+
readonly verdict: PromotionVerdict;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface CascadeAbort {
|
|
207
|
+
readonly outcome: 'abort';
|
|
208
|
+
readonly candidate_id: string | null;
|
|
209
|
+
/** The FIRST failing stage. The cascade does not continue past it. */
|
|
210
|
+
readonly failed_stage: StageId;
|
|
211
|
+
readonly family: FailureFamily;
|
|
212
|
+
readonly detail: string;
|
|
213
|
+
readonly stages_run: readonly StageId[];
|
|
214
|
+
readonly model_calls: 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Stages 1-5 passed and the verdict stage was NOT REACHED because the run
|
|
219
|
+
* supplied no measurements.
|
|
220
|
+
*
|
|
221
|
+
* This is deliberately a third outcome and not an abort. A run that materialises
|
|
222
|
+
* candidates without measuring them has not failed — materialising is what
|
|
223
|
+
* `evolution_lab run` is for, and turning that into a non-zero exit would change
|
|
224
|
+
* a verb's contract to make a new stage look reachable. Nor is it a pass: there
|
|
225
|
+
* is no verdict, so nothing may read one. Naming the state is what keeps "no
|
|
226
|
+
* metrics" from being silently benign.
|
|
227
|
+
*/
|
|
228
|
+
export interface CascadeIncomplete {
|
|
229
|
+
readonly outcome: 'incomplete';
|
|
230
|
+
readonly candidate_id: string;
|
|
231
|
+
readonly stages_run: readonly StageId[];
|
|
232
|
+
readonly model_calls: 0;
|
|
233
|
+
readonly not_reached: CascadeStage;
|
|
234
|
+
readonly why: string;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export type CascadeResult = CascadePass | CascadeAbort | CascadeIncomplete;
|
|
238
|
+
|
|
239
|
+
export interface CascadeInput {
|
|
240
|
+
/** The raw, unparsed record. Stage 1 is what turns it into a `CandidateRecord`. */
|
|
241
|
+
readonly raw: unknown;
|
|
242
|
+
readonly plan: RunPlan;
|
|
243
|
+
readonly budget: RunBudget;
|
|
244
|
+
/** Sibling candidate texts, for the near-duplicate screen. */
|
|
245
|
+
readonly peers?: readonly string[] | undefined;
|
|
246
|
+
/** Metric rows, when the caller has them. Absent means the run measured nothing. */
|
|
247
|
+
readonly rows?: readonly MetricRow[] | undefined;
|
|
248
|
+
/**
|
|
249
|
+
* An already-built vector, when the caller has one.
|
|
250
|
+
*
|
|
251
|
+
* Takes precedence over `rows`. Step 5.6's run report parses vectors from
|
|
252
|
+
* `--vector` and validates them through `buildVector`, so re-deriving one
|
|
253
|
+
* here from loose rows would give the verdict a second, less-checked path
|
|
254
|
+
* to the same number.
|
|
255
|
+
*/
|
|
256
|
+
readonly vector?: MetricVector | undefined;
|
|
257
|
+
/**
|
|
258
|
+
* An activation receipt for the artefact this candidate produces, when one
|
|
259
|
+
* was observed.
|
|
260
|
+
*
|
|
261
|
+
* ABSENT means no receipt was produced, which is not the same as a receipt
|
|
262
|
+
* whose rungs are unknown: with no receipt the six receipt-bearing stages do
|
|
263
|
+
* not run at all, and `stages_run` says so. Only
|
|
264
|
+
* `_lib/activation_receipt_producer.ts` mints these — the cascade consumes a
|
|
265
|
+
* receipt and never derives one, which is the direction
|
|
266
|
+
* `docs/contracts/activation-receipt-trust-boundary.md` TB-1 fixes.
|
|
267
|
+
*/
|
|
268
|
+
readonly receipt?: ActivationReceipt | undefined;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function abort(
|
|
272
|
+
stage: StageId,
|
|
273
|
+
detail: string,
|
|
274
|
+
run: StageId[],
|
|
275
|
+
candidate_id: string | null,
|
|
276
|
+
): CascadeAbort {
|
|
277
|
+
return {
|
|
278
|
+
outcome: 'abort',
|
|
279
|
+
candidate_id,
|
|
280
|
+
failed_stage: stage,
|
|
281
|
+
family: familyForStage(stage),
|
|
282
|
+
detail,
|
|
283
|
+
stages_run: [...run],
|
|
284
|
+
model_calls: 0,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Run the deterministic prefix over one candidate.
|
|
290
|
+
*
|
|
291
|
+
* Aborts on the FIRST hard failure — later stages are not attempted, so a
|
|
292
|
+
* candidate that fails stage 1 never reaches stage 6. `model_calls` is a literal
|
|
293
|
+
* `0` on every path rather than a counter, because a counter can be wrong and a
|
|
294
|
+
* literal cannot: there is no code path in this module that could increment one.
|
|
295
|
+
*/
|
|
296
|
+
export function runCascade(input: CascadeInput): CascadeResult {
|
|
297
|
+
const run: StageId[] = [];
|
|
298
|
+
|
|
299
|
+
// Stage 1 — schema validity. The cheapest, and the one whose failure must
|
|
300
|
+
// cost nothing: it runs before any other stage touches the record.
|
|
301
|
+
run.push('schema-validity');
|
|
302
|
+
let record: CandidateRecord;
|
|
303
|
+
try {
|
|
304
|
+
record = parseCandidateRecord(input.raw);
|
|
305
|
+
} catch (e) {
|
|
306
|
+
// ATTRIBUTION, not just error handling. `parseMutations` already calls
|
|
307
|
+
// `assertMutationPathsOwned` (`_lib/candidate_record.ts:434`), so an
|
|
308
|
+
// unowned path throws from INSIDE the parse. Catching everything here
|
|
309
|
+
// as a schema failure would file a path-ownership violation under the
|
|
310
|
+
// wrong stage and the wrong evidence — the failing stage is what Phase
|
|
311
|
+
// 1's classification reads, so getting it wrong is not cosmetic.
|
|
312
|
+
if (e instanceof PathOwnershipError) {
|
|
313
|
+
run.push('path-ownership');
|
|
314
|
+
return abort('path-ownership', e.message, run, null);
|
|
315
|
+
}
|
|
316
|
+
const why = e instanceof CandidateSchemaError ? e.message : String(e);
|
|
317
|
+
return abort('schema-validity', why, run, null);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Stage 2 — path ownership, re-checked.
|
|
321
|
+
//
|
|
322
|
+
// HONEST LABEL: this branch is UNREACHABLE through this function's public
|
|
323
|
+
// API today, and therefore untested. `runCascade` only ever accepts `raw`
|
|
324
|
+
// and parses it, and `parseMutations` already calls
|
|
325
|
+
// `assertMutationPathsOwned` (`_lib/candidate_record.ts:434`), so every
|
|
326
|
+
// unowned path throws inside stage 1 and is attributed above. Measured
|
|
327
|
+
// 2026-08-31 by neutralising the abort below: 15/15 stayed green.
|
|
328
|
+
//
|
|
329
|
+
// It is kept rather than deleted because it is free and it fails closed if
|
|
330
|
+
// the parser ever stops enforcing ownership — but "defense in depth" is a
|
|
331
|
+
// claim, and this one has no test behind it, so it is written down as an
|
|
332
|
+
// unproven guard instead of counted as a proven one. Deleting it is a
|
|
333
|
+
// legitimate future call; silently trusting it is not.
|
|
334
|
+
run.push('path-ownership');
|
|
335
|
+
try {
|
|
336
|
+
assertMutationPathsOwned(record.mutations);
|
|
337
|
+
} catch (e) {
|
|
338
|
+
const why = e instanceof PathOwnershipError ? e.message : String(e);
|
|
339
|
+
return abort('path-ownership', why, run, record.id);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Stage 3 — holdout disclosure. The guard that REFUSES disclosure to a
|
|
343
|
+
// proposer already exists and exits non-zero at its own call sites; what
|
|
344
|
+
// this stage adds is that a record carrying a holdout reference never
|
|
345
|
+
// reaches the verdict stage.
|
|
346
|
+
run.push('holdout-disclosure');
|
|
347
|
+
const leaked = record.mutations.find((m) => m.path.includes('holdout'));
|
|
348
|
+
if (leaked !== undefined) {
|
|
349
|
+
return abort('holdout-disclosure', `mutation path names a holdout: ${leaked.path}`, run, record.id);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Stage 4 — budget. Before any expensive stage, and before any spend.
|
|
353
|
+
run.push('budget');
|
|
354
|
+
try {
|
|
355
|
+
assertWithinBudget(input.plan, input.budget);
|
|
356
|
+
} catch (e) {
|
|
357
|
+
const why = e instanceof BudgetExceededError ? e.message : String(e);
|
|
358
|
+
return abort('budget', why, run, record.id);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Stage 5 — near-duplicate screen. Deterministic, zero model calls, and it
|
|
362
|
+
// runs BEFORE the verdict so a paraphrase never consumes a measurement.
|
|
363
|
+
run.push('near-duplicate');
|
|
364
|
+
const peers = input.peers ?? [];
|
|
365
|
+
if (peers.length > 0 && diversityCollapsed([record.id, ...peers])) {
|
|
366
|
+
return abort('near-duplicate', 'candidate set collapsed to duplicates', run, record.id);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Stages 6-11 — the RECEIPT-BEARING stages, one per ladder rung.
|
|
370
|
+
//
|
|
371
|
+
// Placed here, and the placement is the evidence-cost contract's EC-2: after
|
|
372
|
+
// every record-only, plan-only and peer-only stage, and before the
|
|
373
|
+
// measurement stage whose evidence is the most expensive to obtain. Reading
|
|
374
|
+
// a receipt is cheap but not precondition-free — how far an artefact climbed
|
|
375
|
+
// says nothing useful about a candidate that is not even well-formed.
|
|
376
|
+
//
|
|
377
|
+
// NO RECEIPT MEANS NO STAGES RUN. Not "six stages that returned unknown":
|
|
378
|
+
// the ladder already distinguishes absent from negative, and running a stage
|
|
379
|
+
// over evidence that does not exist would put six entries in `stages_run`
|
|
380
|
+
// that observed nothing. `stages_run` is what a reader counts coverage from.
|
|
381
|
+
if (input.receipt !== undefined) {
|
|
382
|
+
const stall = firstStall(input.receipt);
|
|
383
|
+
if (stall !== null) {
|
|
384
|
+
// Every stage up to the stall ran; the stall's own stage ran and is
|
|
385
|
+
// where the walk stopped. Abort-on-first, same rule as the prefix.
|
|
386
|
+
const upTo = LADDER.findIndex((s) => s.rung === stall.rung);
|
|
387
|
+
for (let i = 0; i <= upTo; i += 1) run.push(RECEIPT_STAGES[i] as ReceiptStage);
|
|
388
|
+
if (stall.family !== 'unknown') {
|
|
389
|
+
// A rung that was OBSERVED not to be reached. This is the only
|
|
390
|
+
// path on which the cascade assigns `activation` or `adherence`,
|
|
391
|
+
// and it assigns it from the receipt rather than from any
|
|
392
|
+
// property of the candidate.
|
|
393
|
+
return abort(
|
|
394
|
+
stall.stage,
|
|
395
|
+
`activation stalled at rung '${stall.rung}'` +
|
|
396
|
+
(input.receipt.reason !== undefined ? ` (${input.receipt.reason})` : ''),
|
|
397
|
+
run,
|
|
398
|
+
record.id,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
// An UNOBSERVED rung is not a failure. The cascade has learned
|
|
402
|
+
// nothing about activation and says so by not aborting — folding
|
|
403
|
+
// `unknown` into a refusal would turn every capture gap into a
|
|
404
|
+
// rejected candidate, which is the inflation `ladderRate` keeps out
|
|
405
|
+
// of its own denominator for exactly the same reason.
|
|
406
|
+
} else {
|
|
407
|
+
for (const s of RECEIPT_STAGES) run.push(s);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Stage 12 — metric vector and the promotion verdict. This is the ONLY
|
|
412
|
+
// promoter consulted, and it is `_lib/evaluation_vector.ts`'s, never a
|
|
413
|
+
// second verdict computed here.
|
|
414
|
+
if (input.vector === undefined && (input.rows === undefined || input.rows.length === 0)) {
|
|
415
|
+
return {
|
|
416
|
+
outcome: 'incomplete',
|
|
417
|
+
candidate_id: record.id,
|
|
418
|
+
stages_run: [...run],
|
|
419
|
+
model_calls: 0,
|
|
420
|
+
not_reached: 'metric-verdict',
|
|
421
|
+
why: 'no metric rows: the run measured nothing, so no verdict exists',
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
run.push('metric-verdict');
|
|
425
|
+
let vector: MetricVector;
|
|
426
|
+
if (input.vector !== undefined) {
|
|
427
|
+
if (input.vector.candidate_id !== record.id) {
|
|
428
|
+
return abort(
|
|
429
|
+
'metric-verdict',
|
|
430
|
+
`vector names candidate '${input.vector.candidate_id}', not '${record.id}'`,
|
|
431
|
+
run,
|
|
432
|
+
record.id,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
vector = input.vector;
|
|
436
|
+
} else {
|
|
437
|
+
try {
|
|
438
|
+
vector = buildVector(record.id, input.rows as readonly MetricRow[]);
|
|
439
|
+
} catch (e) {
|
|
440
|
+
return abort('metric-verdict', String((e as Error).message), run, record.id);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const verdict = promotionVerdict(vector);
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
outcome: 'pass',
|
|
447
|
+
candidate_id: record.id,
|
|
448
|
+
stages_run: [...run],
|
|
449
|
+
model_calls: 0,
|
|
450
|
+
verdict,
|
|
451
|
+
};
|
|
452
|
+
}
|