@event4u/agent-config 14.13.0 → 14.14.1
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 +138 -376
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/inbox.md +36 -0
- package/dist/agent-src/commands/pr/create.md +16 -1
- package/dist/agent-src/contexts/execution/terminal-states.md +25 -4
- package/dist/agent-src/rules/code-comment-discipline.md +6 -0
- package/dist/agent-src/rules/missing-skill-recovery.md +2 -2
- package/dist/agent-src/rules/token-budget-discipline.md +2 -2
- 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/registry.js +1 -0
- package/dist/cli-delegate/{chunk-ILMUPLGW.js → chunk-7RXIKF62.js} +159 -86
- package/dist/cli-delegate/cmd_conformance.js +39 -2
- package/dist/cli-delegate/cmd_doctor.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +7 -7
- 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 +1696 -812
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +297 -115
- 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/assurance-capability-registry.json +1 -1
- package/src/config/estate-count-budget.json +20 -1
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +211 -1
- package/src/config/gate-reachability-exemptions.json +1 -0
- package/src/config/gate-violation-baselines.json +9 -3
- package/src/config/preamble-payload-budget.json +19 -11
- package/src/config/rule-stub-ceilings.json +14 -7
- package/src/config/web-launch-readiness.json +4 -2
- package/src/scripts/_cli/_doctor_emit.ts +71 -0
- package/src/scripts/_cli/_doctor_strict.ts +163 -0
- package/src/scripts/_cli/cmd_doctor.ts +16 -37
- package/src/scripts/_dispatch.bash +11 -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/branch_convergence.ts +186 -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/context_observation.ts +132 -0
- package/src/scripts/_lib/continuation_ladder.ts +188 -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/experiment_binding.ts +95 -0
- package/src/scripts/_lib/harness_evolution_guards.ts +10 -0
- package/src/scripts/_lib/ignored_blocker.ts +8 -1
- 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/outcome_envelope.ts +6 -1
- package/src/scripts/_lib/outcome_vocabularies.ts +135 -5
- 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 +79 -1
- package/src/scripts/_lib/release_material.ts +125 -2
- package/src/scripts/_lib/repeated_failure.ts +19 -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/runtime_journal.ts +11 -4
- 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_agent_artifact_location.ts +337 -0
- package/src/scripts/check_artefact_count_messaging.ts +74 -8
- package/src/scripts/check_enforcement_coverage.ts +40 -1
- package/src/scripts/check_estate_count.ts +205 -24
- package/src/scripts/check_release_highlights.ts +113 -14
- package/src/scripts/check_roadmap_trackable.ts +17 -2
- package/src/scripts/check_web_launch_readiness.ts +310 -20
- package/src/scripts/corpus_manifest.ts +235 -0
- package/src/scripts/evolution_lab.ts +234 -9
- package/src/scripts/generate_capability_matrix.ts +16 -9
- package/src/scripts/grade_target_readiness.ts +221 -2
- package/src/scripts/hook_effect_doctor.ts +47 -4
- 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/run_continuation_hook.ts +78 -88
- package/src/scripts/hooks/session_eol_hook.ts +12 -1
- package/src/scripts/hooks/turn_end_gate_hook.ts +9 -1
- package/src/scripts/install-hooks.sh +18 -0
- 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 +748 -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 +199 -0
- package/src/scripts/release_env.ts +35 -0
- package/src/scripts/release_publication.ts +142 -0
- package/src/scripts/roadmap_context.ts +15 -0
- package/src/scripts/skill_scout.ts +633 -0
- package/src/scripts/sync_pr_branch.ts +521 -42
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The corpus and catalogue substrate for the two routing measurements of
|
|
3
|
+
* `road-to-governed-harness-evolution` — step 5.1 (description vs body) and
|
|
4
|
+
* step 6.4 (delivery sets).
|
|
5
|
+
*
|
|
6
|
+
* ONE LOADER, TWO MEASUREMENTS. Both runs need the same three things: the
|
|
7
|
+
* frozen train/holdout partition, the trigger cases, and the skill catalogue
|
|
8
|
+
* the prompts are ranked against. Two loaders over one corpus is how two
|
|
9
|
+
* measurements start disagreeing about what they measured, so there is one.
|
|
10
|
+
*
|
|
11
|
+
* THE SEAL IS ENFORCED BY REFUSAL, NOT BY FILTERING. The holdout partition
|
|
12
|
+
* frozen in `agents/evidence/analysis/trigger-corpus-holdout-2026-08-30.md`
|
|
13
|
+
* says: no analyzer authored in Phase 5 may read the sealed corpora. So
|
|
14
|
+
* `loadTrainCases` never opens a holdout `evals/triggers.json` at all — the
|
|
15
|
+
* partition is computed from the DIRECTORY NAME, before any file is read.
|
|
16
|
+
* Filtering after the read would satisfy the letter and not the seal.
|
|
17
|
+
*
|
|
18
|
+
* The seal covers the corpus files and not the catalogue: a holdout skill's
|
|
19
|
+
* `SKILL.md` is still ranked against, because removing it would measure a
|
|
20
|
+
* catalogue that does not exist. The boundary is stated in the 5.1
|
|
21
|
+
* pre-registration rather than left to be inferred here.
|
|
22
|
+
*
|
|
23
|
+
* NO SCORER OF ITS OWN. Every score comes from `src/shared/skillRanking.ts`,
|
|
24
|
+
* the module the production ranker and the MCP handler already share. A second
|
|
25
|
+
* scorer in a measurement module would measure the measurement.
|
|
26
|
+
*
|
|
27
|
+
* Pure of network and of clock; reads files and nothing else.
|
|
28
|
+
*/
|
|
29
|
+
import * as fs from 'node:fs';
|
|
30
|
+
import { createHash } from 'node:crypto';
|
|
31
|
+
import * as path from 'node:path';
|
|
32
|
+
|
|
33
|
+
import { scoreSkill, tokenize, type RankableSkill } from '../../shared/skillRanking.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The partition constant the freeze artefact fixes. Changing it re-partitions
|
|
37
|
+
* the corpus and voids every result measured under the old one, which is why
|
|
38
|
+
* it is a named export a test can pin rather than a literal.
|
|
39
|
+
*/
|
|
40
|
+
export const HOLDOUT_CEILING = 51;
|
|
41
|
+
|
|
42
|
+
export type Partition = 'holdout' | 'train';
|
|
43
|
+
|
|
44
|
+
/** `holdout iff sha256(<skill-directory-name>)[0] < 51`, from the freeze artefact. */
|
|
45
|
+
export function partitionOf(skill: string): Partition {
|
|
46
|
+
const bucket = parseInt(createHash('sha256').update(skill).digest('hex').slice(0, 2), 16);
|
|
47
|
+
return bucket < HOLDOUT_CEILING ? 'holdout' : 'train';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One adjudicated case: a prompt, and whether the owning skill must be delivered. */
|
|
51
|
+
export interface CorpusCase {
|
|
52
|
+
readonly skill: string;
|
|
53
|
+
readonly prompt: string;
|
|
54
|
+
/** `true` = positive (must be delivered) · `false` = negative (must not). */
|
|
55
|
+
readonly expect: boolean;
|
|
56
|
+
/** Step 2.3's vocabulary where the file declares it; 876 of 931 cases do not. */
|
|
57
|
+
readonly caseClass: 'exemplar' | 'near-miss' | 'counterexample' | null;
|
|
58
|
+
readonly language: string | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface RawQuery {
|
|
62
|
+
q?: unknown;
|
|
63
|
+
trigger?: unknown;
|
|
64
|
+
class?: unknown;
|
|
65
|
+
language?: unknown;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The two corpus shapes this tree carries.
|
|
70
|
+
*
|
|
71
|
+
* `queries[]` is the modern one. Two train files — `brand-asset-generation` and
|
|
72
|
+
* `estimate-ticket` — are the legacy `should_trigger` / `should_not_trigger`
|
|
73
|
+
* pair that step 2.3 grandfathered, and `lint_skill_trigger_corpus` returns
|
|
74
|
+
* before its class rules for exactly those two. A reader that understood only
|
|
75
|
+
* the modern shape would drop them SILENTLY, which is worse than refusing them:
|
|
76
|
+
* the run would report 80 train corpora where the partition says 82 and nothing
|
|
77
|
+
* would say which two were missing. So both shapes are read, and
|
|
78
|
+
* `legacyShaped` records which file used which.
|
|
79
|
+
*/
|
|
80
|
+
interface RawCorpus {
|
|
81
|
+
queries?: RawQuery[];
|
|
82
|
+
should_trigger?: unknown;
|
|
83
|
+
should_not_trigger?: unknown;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function skillsDir(repoRoot: string): string {
|
|
87
|
+
return path.join(repoRoot, 'src', 'skills');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Is this `src/skills/<name>` entry test scaffolding rather than a skill?
|
|
92
|
+
*
|
|
93
|
+
* A `__`-prefixed directory is scaffolding by convention, and a real skill can
|
|
94
|
+
* never be named one: `skill.schema.json` pins `name` to `^[a-z][a-z0-9-]*$`
|
|
95
|
+
* and requires it to match the parent directory, so a leading underscore fails
|
|
96
|
+
* `validate_frontmatter`. This predicate therefore cannot hide a shipped skill.
|
|
97
|
+
*
|
|
98
|
+
* WHY IT EXISTS, measured rather than assumed.
|
|
99
|
+
* `tests/scripts/lint_originality.test.ts:64` writes a re-skin fixture to
|
|
100
|
+
* `src/skills/__origtest_reskin_fixture/SKILL.md` in the REAL tree and removes
|
|
101
|
+
* it in `afterEach`, because the linter resolves its corpus from a
|
|
102
|
+
* module-level ROOT and takes no root argument. During that window any
|
|
103
|
+
* concurrently-running test file that counts the catalogue sees 300 where the
|
|
104
|
+
* commit holds 299 — which is what failed
|
|
105
|
+
* `tests/scripts/routing_signal_measurement.test.ts` on
|
|
106
|
+
* `Node Tests (ubuntu-latest, shard 2/4)` in runs 33418425604 and 33424783559,
|
|
107
|
+
* intermittently, while passing in isolation and passing on the next run over
|
|
108
|
+
* two markdown files nothing reads.
|
|
109
|
+
*
|
|
110
|
+
* Reproduced locally before this line was written: with the fixture present on
|
|
111
|
+
* disk the assertion fails `300` vs `299`, character for character the CI
|
|
112
|
+
* message; with this predicate active it passes.
|
|
113
|
+
*
|
|
114
|
+
* Excluding the prefix here rather than threading a root through the linter is
|
|
115
|
+
* the smaller change by an order of magnitude: ROOT is baked into ten
|
|
116
|
+
* module-level constants there, and refactoring a shipped gate to suit a test
|
|
117
|
+
* is a worse trade than a naming convention the tree already follows.
|
|
118
|
+
*/
|
|
119
|
+
export function isScaffoldingSkillDir(name: string): boolean {
|
|
120
|
+
return name.startsWith('__');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Every skill directory that carries a trigger corpus, with its partition. */
|
|
124
|
+
export function corpusSkills(repoRoot: string): { skill: string; partition: Partition }[] {
|
|
125
|
+
const dir = skillsDir(repoRoot);
|
|
126
|
+
if (!fs.existsSync(dir)) return [];
|
|
127
|
+
return fs
|
|
128
|
+
.readdirSync(dir)
|
|
129
|
+
.sort()
|
|
130
|
+
.filter((skill) => !isScaffoldingSkillDir(skill))
|
|
131
|
+
.filter((skill) => fs.existsSync(path.join(dir, skill, 'evals', 'triggers.json')))
|
|
132
|
+
.map((skill) => ({ skill, partition: partitionOf(skill) }));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The train cases. A holdout corpus is never opened — the partition is decided
|
|
137
|
+
* from the directory name and the sealed path is skipped before any read.
|
|
138
|
+
*/
|
|
139
|
+
export function loadTrainCases(repoRoot: string): CorpusCase[] {
|
|
140
|
+
const dir = skillsDir(repoRoot);
|
|
141
|
+
const out: CorpusCase[] = [];
|
|
142
|
+
for (const { skill, partition } of corpusSkills(repoRoot)) {
|
|
143
|
+
if (partition === 'holdout') continue;
|
|
144
|
+
const raw = JSON.parse(
|
|
145
|
+
fs.readFileSync(path.join(dir, skill, 'evals', 'triggers.json'), 'utf8'),
|
|
146
|
+
) as RawCorpus;
|
|
147
|
+
if (Array.isArray(raw.queries)) {
|
|
148
|
+
for (const q of raw.queries) {
|
|
149
|
+
if (typeof q.q !== 'string' || typeof q.trigger !== 'boolean') continue;
|
|
150
|
+
const declared = typeof q.class === 'string' ? q.class : null;
|
|
151
|
+
out.push({
|
|
152
|
+
skill,
|
|
153
|
+
prompt: q.q,
|
|
154
|
+
expect: q.trigger,
|
|
155
|
+
caseClass:
|
|
156
|
+
declared === 'exemplar' ||
|
|
157
|
+
declared === 'near-miss' ||
|
|
158
|
+
declared === 'counterexample'
|
|
159
|
+
? declared
|
|
160
|
+
: null,
|
|
161
|
+
language: typeof q.language === 'string' ? q.language : null,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
for (const [key, expect] of [
|
|
167
|
+
['should_trigger', true],
|
|
168
|
+
['should_not_trigger', false],
|
|
169
|
+
] as const) {
|
|
170
|
+
const list = raw[key];
|
|
171
|
+
if (!Array.isArray(list)) continue;
|
|
172
|
+
for (const prompt of list) {
|
|
173
|
+
if (typeof prompt !== 'string') continue;
|
|
174
|
+
out.push({ skill, prompt, expect, caseClass: null, language: null });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Which train corpora use the legacy shape. Reported so the count is explainable. */
|
|
182
|
+
export function legacyShaped(repoRoot: string): string[] {
|
|
183
|
+
const dir = skillsDir(repoRoot);
|
|
184
|
+
const out: string[] = [];
|
|
185
|
+
for (const { skill, partition } of corpusSkills(repoRoot)) {
|
|
186
|
+
if (partition === 'holdout') continue;
|
|
187
|
+
const raw = JSON.parse(
|
|
188
|
+
fs.readFileSync(path.join(dir, skill, 'evals', 'triggers.json'), 'utf8'),
|
|
189
|
+
) as RawCorpus;
|
|
190
|
+
if (!Array.isArray(raw.queries)) out.push(skill);
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** A catalogue entry: what the ranker sees, plus the body the 5.1 arm adds. */
|
|
196
|
+
export interface CatalogueEntry extends RankableSkill {
|
|
197
|
+
readonly name: string;
|
|
198
|
+
readonly description: string;
|
|
199
|
+
readonly personas: readonly string[];
|
|
200
|
+
/** SKILL.md with its frontmatter removed. Empty when the file has none. */
|
|
201
|
+
readonly body: string;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Minimal frontmatter split. Returns `[block, body]`; `['', text]` when absent. */
|
|
205
|
+
export function splitFrontmatter(text: string): [string, string] {
|
|
206
|
+
if (!text.startsWith('---')) return ['', text];
|
|
207
|
+
const end = text.indexOf('\n---', 3);
|
|
208
|
+
if (end === -1) return ['', text];
|
|
209
|
+
return [text.slice(3, end), text.slice(end + 4)];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** `name`, `description` and `personas` out of a frontmatter block. */
|
|
213
|
+
export function readMeta(block: string): { name: string; description: string; personas: string[] } {
|
|
214
|
+
let name = '';
|
|
215
|
+
let description = '';
|
|
216
|
+
const personas: string[] = [];
|
|
217
|
+
let inPersonas = false;
|
|
218
|
+
for (const raw of block.split('\n')) {
|
|
219
|
+
const line = raw.replace(/\s+$/, '');
|
|
220
|
+
if (inPersonas && /^ {2}- /.test(line)) {
|
|
221
|
+
personas.push(line.slice(4).trim().replace(/^["']|["']$/g, ''));
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
inPersonas = false;
|
|
225
|
+
const m = /^([a-zA-Z_][\w-]*)[ \t]*:[ \t]*(.*)$/.exec(line);
|
|
226
|
+
if (!m) continue;
|
|
227
|
+
const key = m[1] as string;
|
|
228
|
+
const value = (m[2] as string).trim().replace(/^["']|["']$/g, '');
|
|
229
|
+
if (key === 'personas' && value === '') inPersonas = true;
|
|
230
|
+
else if (key === 'name') name = value;
|
|
231
|
+
else if (key === 'description') description = value;
|
|
232
|
+
}
|
|
233
|
+
return { name, description, personas };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Every skill directory`s `SKILL.md`, name-sorted. The ranking universe. */
|
|
237
|
+
export function loadCatalogue(repoRoot: string): CatalogueEntry[] {
|
|
238
|
+
const dir = skillsDir(repoRoot);
|
|
239
|
+
if (!fs.existsSync(dir)) return [];
|
|
240
|
+
const out: CatalogueEntry[] = [];
|
|
241
|
+
for (const skill of fs.readdirSync(dir).sort()) {
|
|
242
|
+
if (isScaffoldingSkillDir(skill)) continue;
|
|
243
|
+
const file = path.join(dir, skill, 'SKILL.md');
|
|
244
|
+
if (!fs.existsSync(file)) continue;
|
|
245
|
+
const [block, body] = splitFrontmatter(fs.readFileSync(file, 'utf8'));
|
|
246
|
+
const meta = readMeta(block);
|
|
247
|
+
out.push({
|
|
248
|
+
name: meta.name || skill,
|
|
249
|
+
description: meta.description,
|
|
250
|
+
personas: meta.personas,
|
|
251
|
+
body,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The two 5.1 arms. `description` is today's production condition. */
|
|
258
|
+
export const ARMS = ['description', 'description+body'] as const;
|
|
259
|
+
export type Arm = (typeof ARMS)[number];
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The indexed term set per arm.
|
|
263
|
+
*
|
|
264
|
+
* The `description` arm reproduces `skillTerms(skill, {})` exactly — same
|
|
265
|
+
* inputs, same tokenizer — so the control arm IS the shipped behaviour rather
|
|
266
|
+
* than a re-implementation of it.
|
|
267
|
+
*/
|
|
268
|
+
export function armTerms(entry: CatalogueEntry, arm: Arm): Set<string> {
|
|
269
|
+
const parts = [entry.name, entry.description];
|
|
270
|
+
if (arm === 'description+body') parts.push(entry.body);
|
|
271
|
+
return tokenize(parts.join(' '));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export interface RankedName {
|
|
275
|
+
readonly name: string;
|
|
276
|
+
readonly score: number;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Top-k by score, ties broken on name — the total order `rankSkills` already
|
|
281
|
+
* defines. Zero scores are dropped, because a skill the scorer gives 0 is not
|
|
282
|
+
* delivered by the shipped ranker either.
|
|
283
|
+
*/
|
|
284
|
+
export function topK(
|
|
285
|
+
prompt: string,
|
|
286
|
+
catalogue: readonly CatalogueEntry[],
|
|
287
|
+
terms: ReadonlyMap<string, Set<string>>,
|
|
288
|
+
k: number,
|
|
289
|
+
): RankedName[] {
|
|
290
|
+
const taskTerms = tokenize(prompt);
|
|
291
|
+
const rows: RankedName[] = [];
|
|
292
|
+
for (const entry of catalogue) {
|
|
293
|
+
const t = terms.get(entry.name);
|
|
294
|
+
if (t === undefined) continue;
|
|
295
|
+
const score = scoreSkill(taskTerms, entry, t);
|
|
296
|
+
if (score > 0) rows.push({ name: entry.name, score });
|
|
297
|
+
}
|
|
298
|
+
rows.sort((a, b) => b.score - a.score || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
299
|
+
return rows.slice(0, k);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Term sets for one arm, keyed by the name `topK` ranks on. */
|
|
303
|
+
export function termIndex(catalogue: readonly CatalogueEntry[], arm: Arm): Map<string, Set<string>> {
|
|
304
|
+
const index = new Map<string, Set<string>>();
|
|
305
|
+
for (const entry of catalogue) index.set(entry.name, armTerms(entry, arm));
|
|
306
|
+
return index;
|
|
307
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* routing_index_input — what the skill index is built OVER, derived from the
|
|
3
|
+
* 5.1 verdict file (`road-to-governed-harness-evolution` step 6.5).
|
|
4
|
+
*
|
|
5
|
+
* Step 6.5 reads: *"Index the body only if 5.1 measured a signal. Otherwise
|
|
6
|
+
* description-only. verify: the indexer`s input set derives from the 5.1
|
|
7
|
+
* verdict file."*
|
|
8
|
+
*
|
|
9
|
+
* SO THE ANSWER IS NOT WRITTEN HERE. This module contains no opinion about the
|
|
10
|
+
* body. It opens
|
|
11
|
+
* `agents/evidence/analysis/routing-body-signal-verdict.json`, reads
|
|
12
|
+
* `body_signal.verdict`, and returns the input set that verdict licenses. Flip
|
|
13
|
+
* the file and the input set flips; delete the file and it falls back. A module
|
|
14
|
+
* that hardcoded `description` would satisfy the step`s OUTCOME today and fail
|
|
15
|
+
* its verify, because the outcome would not derive from anything.
|
|
16
|
+
*
|
|
17
|
+
* FAIL-CLOSED, IN THE DIRECTION THAT COSTS LESS. Missing file, unparseable
|
|
18
|
+
* JSON, unknown verdict token, or a missing `proxy_to_real_fidelity` field all
|
|
19
|
+
* resolve to **description-only** with the reason recorded. Only the literal
|
|
20
|
+
* token `signal` widens the index. The asymmetry is deliberate: a stale or
|
|
21
|
+
* broken record must never be able to widen what the suite indexes, and the
|
|
22
|
+
* measured cost of widening on a `harmful` verdict is +7.22 pp of false
|
|
23
|
+
* activation.
|
|
24
|
+
*
|
|
25
|
+
* WHY THE FIDELITY FIELD GATES THE READ. The 5.1 verdict ships its own bound —
|
|
26
|
+
* `proxy_to_real_fidelity`, null and unmeasured-by-construction — precisely so
|
|
27
|
+
* a consumer cannot take the conclusion without it. A record that has lost that
|
|
28
|
+
* field is a record whose provenance has been edited, and this resolver refuses
|
|
29
|
+
* it rather than trusting the half that survived.
|
|
30
|
+
*
|
|
31
|
+
* Pure of network and of clock; reads one file.
|
|
32
|
+
*/
|
|
33
|
+
import * as fs from 'node:fs';
|
|
34
|
+
import * as path from 'node:path';
|
|
35
|
+
|
|
36
|
+
/** Where step 5.1 writes its machine-readable verdict. */
|
|
37
|
+
export const VERDICT_REL = 'agents/evidence/analysis/routing-body-signal-verdict.json';
|
|
38
|
+
|
|
39
|
+
/** The two input sets step 6.5 can resolve to. Ordered, so they are comparable. */
|
|
40
|
+
export const DESCRIPTION_ONLY = ['name', 'description'] as const;
|
|
41
|
+
export const DESCRIPTION_AND_BODY = ['name', 'description', 'body'] as const;
|
|
42
|
+
|
|
43
|
+
export interface IndexInput {
|
|
44
|
+
readonly fields: readonly string[];
|
|
45
|
+
/** The token read from the verdict file, or `null` when none could be read. */
|
|
46
|
+
readonly verdict: string | null;
|
|
47
|
+
readonly reason: string;
|
|
48
|
+
/** `true` only when the verdict file licensed the wider set. */
|
|
49
|
+
readonly indexesBody: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const descriptionOnly = (verdict: string | null, reason: string): IndexInput => ({
|
|
53
|
+
fields: DESCRIPTION_ONLY,
|
|
54
|
+
verdict,
|
|
55
|
+
reason,
|
|
56
|
+
indexesBody: false,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The input set the 5.1 verdict at `repoRoot` licenses.
|
|
61
|
+
*
|
|
62
|
+
* `verdictPath` exists so a test can point the resolver at a fixture record
|
|
63
|
+
* instead of writing to the tracked one.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveIndexInput(repoRoot: string, verdictPath?: string): IndexInput {
|
|
66
|
+
const file = verdictPath ?? path.join(repoRoot, VERDICT_REL);
|
|
67
|
+
let parsed: Record<string, unknown>;
|
|
68
|
+
try {
|
|
69
|
+
parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as Record<string, unknown>;
|
|
70
|
+
} catch {
|
|
71
|
+
return descriptionOnly(null, `no readable 5.1 verdict at ${VERDICT_REL} — fail-closed`);
|
|
72
|
+
}
|
|
73
|
+
if (parsed['proxy_to_real_fidelity'] === undefined) {
|
|
74
|
+
return descriptionOnly(
|
|
75
|
+
null,
|
|
76
|
+
'the 5.1 verdict carries no proxy_to_real_fidelity bound — refused, fail-closed',
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const body = parsed['body_signal'];
|
|
80
|
+
const token =
|
|
81
|
+
typeof body === 'object' && body !== null
|
|
82
|
+
? (body as Record<string, unknown>)['verdict']
|
|
83
|
+
: undefined;
|
|
84
|
+
if (typeof token !== 'string') {
|
|
85
|
+
return descriptionOnly(null, 'the 5.1 verdict carries no body_signal.verdict — fail-closed');
|
|
86
|
+
}
|
|
87
|
+
if (token === 'signal') {
|
|
88
|
+
return {
|
|
89
|
+
fields: DESCRIPTION_AND_BODY,
|
|
90
|
+
verdict: token,
|
|
91
|
+
reason: 'the 5.1 verdict is `signal` — the body is indexed',
|
|
92
|
+
indexesBody: true,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return descriptionOnly(token, `the 5.1 verdict is \`${token}\`, not \`signal\` — description-only`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The `Arm` name the routing corpus uses for the resolved input set. */
|
|
99
|
+
export function resolveIndexArm(
|
|
100
|
+
repoRoot: string,
|
|
101
|
+
verdictPath?: string,
|
|
102
|
+
): 'description' | 'description+body' {
|
|
103
|
+
return resolveIndexInput(repoRoot, verdictPath).indexesBody ? 'description+body' : 'description';
|
|
104
|
+
}
|
|
@@ -214,13 +214,34 @@ export function selectForInjection(
|
|
|
214
214
|
repoRoot: string,
|
|
215
215
|
matches: TierRuleMatch[],
|
|
216
216
|
capBytes: number,
|
|
217
|
+
shortlist?: readonly string[] | null,
|
|
217
218
|
): SelectionResult {
|
|
218
219
|
const bodyBytes = new Map<string, number>();
|
|
219
220
|
for (const m of matches) {
|
|
220
221
|
const body = loadRuleBody(repoRoot, m.id);
|
|
221
222
|
bodyBytes.set(m.id, body === null ? 0 : bytesOf(body));
|
|
222
223
|
}
|
|
223
|
-
|
|
224
|
+
// STEP 6.3 — the optional lexical shortlist, as a TIE-BREAK and nothing more.
|
|
225
|
+
//
|
|
226
|
+
// Absent (the default), the cap order is exactly what it was: score, then
|
|
227
|
+
// router order. Present, it sits BETWEEN them, never above `score` — the
|
|
228
|
+
// matcher's verdict is read first and the shortlist only orders what the
|
|
229
|
+
// matcher already tied. Membership is untouched in both directions: a
|
|
230
|
+
// shortlisted id the matcher did not return is not in `matches` and so is
|
|
231
|
+
// never consulted, and no matched id is dropped for being unshortlisted
|
|
232
|
+
// (it sorts at Infinity, behind the shortlisted ones, and still competes
|
|
233
|
+
// for the cap). That is `_lib/lexical_shortlist.ts`'s "never decides
|
|
234
|
+
// alone" expressed at the one place the cap actually binds.
|
|
235
|
+
const rank = new Map<string, number>();
|
|
236
|
+
for (const [i, id] of (shortlist ?? []).entries()) {
|
|
237
|
+
if (!rank.has(id)) rank.set(id, i);
|
|
238
|
+
}
|
|
239
|
+
const ranked = [...matches].sort(
|
|
240
|
+
(a, b) =>
|
|
241
|
+
b.score - a.score ||
|
|
242
|
+
(rank.get(a.id) ?? Infinity) - (rank.get(b.id) ?? Infinity) ||
|
|
243
|
+
a.order - b.order,
|
|
244
|
+
);
|
|
224
245
|
const selected: TierRuleMatch[] = [];
|
|
225
246
|
const dropped: TierRuleMatch[] = [];
|
|
226
247
|
let total = 0;
|
|
@@ -173,7 +173,7 @@ import type { DatabaseSync } from 'node:sqlite';
|
|
|
173
173
|
|
|
174
174
|
import { current_branch, git_common_dir, git_dir } from './git_common_dir.js';
|
|
175
175
|
import type { TerminalState } from './outcome_envelope.js';
|
|
176
|
-
import { RUN_TERMINAL_STATES } from './outcome_vocabularies.js';
|
|
176
|
+
import { readRunTerminalState, RUN_TERMINAL_STATES } from './outcome_vocabularies.js';
|
|
177
177
|
import {
|
|
178
178
|
isSqliteAvailableSync,
|
|
179
179
|
loadSqliteSync,
|
|
@@ -303,7 +303,7 @@ export const CONSUMPTION_STATES = ['consumed', 'partially-consumed', 'rejected-w
|
|
|
303
303
|
export type Consumption = (typeof CONSUMPTION_STATES)[number];
|
|
304
304
|
|
|
305
305
|
/**
|
|
306
|
-
* The
|
|
306
|
+
* The run terminal states as a runtime array.
|
|
307
307
|
*
|
|
308
308
|
* Re-export of `RUN_TERMINAL_STATES` from `outcome_vocabularies.ts`. It used to
|
|
309
309
|
* be a second literal list here, guarded by two type-level assertions that
|
|
@@ -316,6 +316,12 @@ export type Consumption = (typeof CONSUMPTION_STATES)[number];
|
|
|
316
316
|
*
|
|
317
317
|
* The name is kept because it is exported and pinned by
|
|
318
318
|
* `tests/scripts/runtime_journal.test.ts:205,207`.
|
|
319
|
+
*
|
|
320
|
+
* Growing this list does NOT move {@link JOURNAL_SCHEMA_VERSION}: that covers
|
|
321
|
+
* tables and columns and a mismatch discards the store, while a widened value
|
|
322
|
+
* domain changes neither. The domain carries its own version in the registry
|
|
323
|
+
* (`RUN_TERMINAL_VOCABULARY_VERSION`), and the read path tolerates a value it
|
|
324
|
+
* does not know rather than trusting the column — see `toEvent`.
|
|
319
325
|
*/
|
|
320
326
|
export const TERMINAL_STATES = RUN_TERMINAL_STATES;
|
|
321
327
|
|
|
@@ -1019,7 +1025,7 @@ export function recordEvent(h: JournalHandle, input: RecordEventInput): JournalE
|
|
|
1019
1025
|
!(TERMINAL_STATES as readonly string[]).includes(input.terminal_state)
|
|
1020
1026
|
) {
|
|
1021
1027
|
throw new JournalContractError(
|
|
1022
|
-
`terminal_state ${JSON.stringify(input.terminal_state)} is not
|
|
1028
|
+
`terminal_state ${JSON.stringify(input.terminal_state)} is not in the run vocabulary.`,
|
|
1023
1029
|
);
|
|
1024
1030
|
}
|
|
1025
1031
|
if (
|
|
@@ -1105,7 +1111,8 @@ function toEvent(r: Record<string, unknown>): JournalEvent {
|
|
|
1105
1111
|
worktree_id: String(r['worktree_id']),
|
|
1106
1112
|
at: String(r['at']),
|
|
1107
1113
|
capability: String(r['capability']),
|
|
1108
|
-
|
|
1114
|
+
// Tolerant, not a cast: a value this build cannot place reads as null.
|
|
1115
|
+
terminal_state: readRunTerminalState(r['terminal_state']),
|
|
1109
1116
|
return_ref: (r['return_ref'] as string | null) ?? null,
|
|
1110
1117
|
verification_ref: (r['verification_ref'] as string | null) ?? null,
|
|
1111
1118
|
consumption: (r['consumption'] as Consumption | null) ?? null,
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The semantic no-op gate — `road-to-harness-promotion-bridge` step 7.4.
|
|
3
|
+
*
|
|
4
|
+
* > *Reject semantic no-ops. A no-op detector plus a minimum
|
|
5
|
+
* > material-improvement threshold. The master kept the cooldown and lineage
|
|
6
|
+
* > from the same attack and dropped both gates.*
|
|
7
|
+
* > verify: **a paraphrase-only candidate is refused before the cascade.**
|
|
8
|
+
*
|
|
9
|
+
* ## "Before the cascade" — what that resolves to in this tree
|
|
10
|
+
*
|
|
11
|
+
* There is no artefact named "cascade" here; the evaluation cascade is the
|
|
12
|
+
* lifecycle spine, `proposed -> diagnostic-evaluated -> selection-evaluated ->
|
|
13
|
+
* …`. So "before the cascade" means: while the candidate is still `proposed`,
|
|
14
|
+
* before the first transition and before anything expensive runs.
|
|
15
|
+
* {@link screenSemanticNoOps} is that stage, and it is SYNCHRONOUS for the same
|
|
16
|
+
* reason `screenNearDuplicates` is — a synchronous function cannot await a
|
|
17
|
+
* network call, so the zero-model-call property follows from the signature
|
|
18
|
+
* rather than from a promise the body has to keep.
|
|
19
|
+
*
|
|
20
|
+
* ## Two gates, because the step asks for two
|
|
21
|
+
*
|
|
22
|
+
* 1. **The no-op detector.** {@link isSemanticNoOp} — a candidate whose text is
|
|
23
|
+
* a paraphrase of what it replaces. Measured with `shingleOverlap`, the same
|
|
24
|
+
* primitive `curator_ops`'s near-duplicate screen and `lint_originality` use.
|
|
25
|
+
* 2. **The minimum material-improvement threshold.** A candidate that is not a
|
|
26
|
+
* paraphrase can still be a rounding error;
|
|
27
|
+
* {@link MIN_MATERIAL_IMPROVEMENT_PERCENT} is the floor its measured delta
|
|
28
|
+
* must clear.
|
|
29
|
+
*
|
|
30
|
+
* Both are needed and neither implies the other: a total rewrite that changes
|
|
31
|
+
* nothing measurable passes (1) and fails (2); a one-word change with a large
|
|
32
|
+
* measured delta passes (2) and fails (1). Shipping one of the two is what the
|
|
33
|
+
* master did.
|
|
34
|
+
*/
|
|
35
|
+
import { NEAR_DUPLICATE_THRESHOLD } from './curator_ops.js';
|
|
36
|
+
import { shingleOverlap } from './shingle_similarity.js';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Containment-overlap percent at which a candidate is a paraphrase of what it
|
|
40
|
+
* replaces.
|
|
41
|
+
*
|
|
42
|
+
* **Measured bound, stated rather than left for a reader to discover.**
|
|
43
|
+
* `shingleOverlap` uses 8-word shingles, so ONE substitution breaks eight
|
|
44
|
+
* shingles. On a one-sentence candidate (~10 shingles) that is already a
|
|
45
|
+
* 45-point drop and no paraphrase can reach 70 %; on rule-body-sized text
|
|
46
|
+
* (~60 words, 56 shingles) the same edit measures 85.7 %. The detector is
|
|
47
|
+
* therefore meaningful for the corpus it will see — rule and skill bodies — and
|
|
48
|
+
* weak for one-liners. `revisit-if` candidates turn out to be short.
|
|
49
|
+
*
|
|
50
|
+
* Pinned EQUAL to {@link NEAR_DUPLICATE_THRESHOLD} rather than given its own
|
|
51
|
+
* number, and the equality is the argument: both questions are "is this text
|
|
52
|
+
* substantially the text we already have", measured on the same primitive, and
|
|
53
|
+
* two independently-tuned constants for one question is how they drift. If one
|
|
54
|
+
* moves, the reason has to cover both.
|
|
55
|
+
*/
|
|
56
|
+
export const PARAPHRASE_OVERLAP_THRESHOLD = NEAR_DUPLICATE_THRESHOLD;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The smallest measured improvement that counts as material, in percentage
|
|
60
|
+
* points.
|
|
61
|
+
*
|
|
62
|
+
* A STATED default, not a measured optimum — the honest label this repository
|
|
63
|
+
* uses for a number nobody has derived. `revisit-if` a real promotion is refused
|
|
64
|
+
* for a delta a human calls material, or admitted for one a human calls noise.
|
|
65
|
+
* It is deliberately small: the gate exists to reject ZERO, not to set a bar for
|
|
66
|
+
* ambition.
|
|
67
|
+
*/
|
|
68
|
+
export const MIN_MATERIAL_IMPROVEMENT_PERCENT = 1;
|
|
69
|
+
|
|
70
|
+
export interface NoOpVerdict {
|
|
71
|
+
readonly isNoOp: boolean;
|
|
72
|
+
/** Why, in one clause. Empty when it is not a no-op. */
|
|
73
|
+
readonly reason: string;
|
|
74
|
+
readonly overlapPercent: number;
|
|
75
|
+
/** Literal 0. This stage is deterministic; a model call here would be a defect. */
|
|
76
|
+
readonly modelCalls: 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Is this candidate a semantic no-op against the text it replaces?
|
|
81
|
+
*
|
|
82
|
+
* Two ways to be one, and the second is the one a paraphrase detector alone
|
|
83
|
+
* misses: the text barely moved, or the text moved and the measured effect did
|
|
84
|
+
* not.
|
|
85
|
+
*/
|
|
86
|
+
export function isSemanticNoOp(
|
|
87
|
+
baselineText: string,
|
|
88
|
+
candidateText: string,
|
|
89
|
+
deltaPercent: number,
|
|
90
|
+
overlapThreshold: number = PARAPHRASE_OVERLAP_THRESHOLD,
|
|
91
|
+
minDelta: number = MIN_MATERIAL_IMPROVEMENT_PERCENT,
|
|
92
|
+
): NoOpVerdict {
|
|
93
|
+
const overlapPercent = shingleOverlap(baselineText, candidateText);
|
|
94
|
+
if (overlapPercent >= overlapThreshold) {
|
|
95
|
+
return {
|
|
96
|
+
isNoOp: true,
|
|
97
|
+
reason:
|
|
98
|
+
`paraphrase-only: ${String(overlapPercent)}% shingle overlap with the text it replaces ` +
|
|
99
|
+
`(threshold ${String(overlapThreshold)}%)`,
|
|
100
|
+
overlapPercent,
|
|
101
|
+
modelCalls: 0,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (deltaPercent < minDelta) {
|
|
105
|
+
return {
|
|
106
|
+
isNoOp: true,
|
|
107
|
+
reason:
|
|
108
|
+
`no material improvement: measured delta ${String(deltaPercent)} pp is below the ` +
|
|
109
|
+
`${String(minDelta)} pp floor`,
|
|
110
|
+
overlapPercent,
|
|
111
|
+
modelCalls: 0,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return { isNoOp: false, reason: '', overlapPercent, modelCalls: 0 };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export class SemanticNoOpError extends Error {
|
|
118
|
+
readonly candidateId: string;
|
|
119
|
+
constructor(candidateId: string, reason: string) {
|
|
120
|
+
super(
|
|
121
|
+
`candidate '${candidateId}' is a semantic no-op and is refused before the cascade: ${reason}. ` +
|
|
122
|
+
'Evaluating it would spend trials to measure nothing, and promoting it would grow the ' +
|
|
123
|
+
'estate by an artefact that says what the estate already says.',
|
|
124
|
+
);
|
|
125
|
+
this.name = 'SemanticNoOpError';
|
|
126
|
+
this.candidateId = candidateId;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @throws {SemanticNoOpError} when the candidate is a no-op. */
|
|
131
|
+
export function assertNotSemanticNoOp(candidateId: string, verdict: NoOpVerdict): void {
|
|
132
|
+
if (verdict.isNoOp) {
|
|
133
|
+
throw new SemanticNoOpError(candidateId, verdict.reason);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface NoOpScreenInput {
|
|
138
|
+
readonly id: string;
|
|
139
|
+
readonly baselineText: string;
|
|
140
|
+
readonly candidateText: string;
|
|
141
|
+
readonly deltaPercent: number;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface NoOpScreenResult {
|
|
145
|
+
readonly admitted: readonly string[];
|
|
146
|
+
readonly refused: readonly { readonly id: string; readonly reason: string }[];
|
|
147
|
+
/** Literal 0 — the whole point of a pre-stage is that it is cheaper than what follows. */
|
|
148
|
+
readonly modelCalls: 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The pre-stage: refuse no-ops while every candidate is still `proposed`.
|
|
153
|
+
*
|
|
154
|
+
* Synchronous, and it takes TEXT rather than records on purpose — it must be
|
|
155
|
+
* callable before a candidate has any evaluation results attached, which is the
|
|
156
|
+
* whole meaning of "before the cascade".
|
|
157
|
+
*/
|
|
158
|
+
export function screenSemanticNoOps(candidates: readonly NoOpScreenInput[]): NoOpScreenResult {
|
|
159
|
+
const admitted: string[] = [];
|
|
160
|
+
const refused: { id: string; reason: string }[] = [];
|
|
161
|
+
for (const c of candidates) {
|
|
162
|
+
const verdict = isSemanticNoOp(c.baselineText, c.candidateText, c.deltaPercent);
|
|
163
|
+
if (verdict.isNoOp) refused.push({ id: c.id, reason: verdict.reason });
|
|
164
|
+
else admitted.push(c.id);
|
|
165
|
+
}
|
|
166
|
+
return { admitted, refused, modelCalls: 0 };
|
|
167
|
+
}
|