@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,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expected information gain per cost — step 8.4.
|
|
3
|
+
*
|
|
4
|
+
* `road-to-inbox-harvest-2026-08-e-council-topology-evidence` step 8.4: *"Score
|
|
5
|
+
* optional next calls by expected information gain per cost, deterministic and
|
|
6
|
+
* inspectable to start"*, verified by *"the score is reproducible from the
|
|
7
|
+
* recorded inputs"*.
|
|
8
|
+
*
|
|
9
|
+
* ## Every feature is one the tree already computes
|
|
10
|
+
*
|
|
11
|
+
* The gain terms are read from {@link DisagreementSignal}
|
|
12
|
+
* (`disagreement_signal.ts:134`), which 6.1 already established as **zero-cost
|
|
13
|
+
* and structural** — every input arrives from a verdict the run has already
|
|
14
|
+
* paid for. No new similarity measure, no new threshold, no model call to
|
|
15
|
+
* decide whether to make a model call. Forking a second feature set here would
|
|
16
|
+
* be the same defect 1A.1 forbids for the question hash: two answers to one
|
|
17
|
+
* question, and the second is the one nobody updates.
|
|
18
|
+
*
|
|
19
|
+
* ## "Deterministic and inspectable" is enforced, not promised
|
|
20
|
+
*
|
|
21
|
+
* - {@link scoreNextCall} is pure: same record in, same bytes out.
|
|
22
|
+
* - Terms are summed in a FIXED declared order, so floating-point association
|
|
23
|
+
* cannot vary with input ordering.
|
|
24
|
+
* - The result carries every term's raw value, weight and contribution, so the
|
|
25
|
+
* number can be re-derived by hand from what is printed.
|
|
26
|
+
* - The published figures are rounded to {@link SCORE_PRECISION} decimals, so a
|
|
27
|
+
* record round-tripped through JSON reproduces bit-identically.
|
|
28
|
+
* - {@link reproduceScore} is the verify clause made executable: recompute from
|
|
29
|
+
* a record's own inputs and compare.
|
|
30
|
+
*
|
|
31
|
+
* ## What an UNAVAILABLE component does, and why it is not zero
|
|
32
|
+
*
|
|
33
|
+
* A component the run could not observe is dropped from BOTH the numerator and
|
|
34
|
+
* the weight denominator. Treating it as 0 would read "not measured" as
|
|
35
|
+
* "measured, and it showed agreement" — the same NOT-RUN-is-not-a-null failure
|
|
36
|
+
* this roadmap records elsewhere. `basisComponents` reports how many terms the
|
|
37
|
+
* gain actually rests on, so a gain over one component is distinguishable from
|
|
38
|
+
* a gain over five, and {@link scoreNextCall} refuses to produce a gain at all
|
|
39
|
+
* when nothing is available.
|
|
40
|
+
*
|
|
41
|
+
* ## Honest scope
|
|
42
|
+
*
|
|
43
|
+
* This scores; it does not decide. 8.5 — stopping when the next call has low
|
|
44
|
+
* expected value — is explicitly gated on Phase 2 evidence
|
|
45
|
+
* (`blocker: phase-2-benchmark-cost`), and the weights below are **declared
|
|
46
|
+
* priors, not fitted values**: nothing has been benchmarked against them.
|
|
47
|
+
* `revisit-if`: the Phase 2 benchmark shows a term that does not predict a
|
|
48
|
+
* changed finding, or shows one that does and is absent here.
|
|
49
|
+
*/
|
|
50
|
+
import type { DisagreementSignal, SignalComponent } from './disagreement_signal.js';
|
|
51
|
+
|
|
52
|
+
/** Decimals every published figure is rounded to. Makes JSON round-trip exact. */
|
|
53
|
+
export const SCORE_PRECISION = 6;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The gain terms, in the FIXED order they are summed.
|
|
57
|
+
*
|
|
58
|
+
* `direction` says how the raw component relates to expected gain:
|
|
59
|
+
* `'rising'` — the component rises with DISAGREEMENT, so it is used as-is;
|
|
60
|
+
* `'falling'` — it rises with AGREEMENT, so `1 - value` is the gain.
|
|
61
|
+
*
|
|
62
|
+
* `weight` is a declared prior. The two divergence terms carry the most because
|
|
63
|
+
* an unresolved split is the one state where another call can still change the
|
|
64
|
+
* verdict; `selfSimilarity` is inverted and weighted low because high
|
|
65
|
+
* self-similarity is exhaustion, which 6.2 already treats as a stop condition
|
|
66
|
+
* rather than as a gain signal.
|
|
67
|
+
*/
|
|
68
|
+
export const GAIN_TERMS: readonly {
|
|
69
|
+
readonly key: keyof DisagreementSignal & string;
|
|
70
|
+
readonly weight: number;
|
|
71
|
+
readonly direction: 'rising' | 'falling';
|
|
72
|
+
readonly why: string;
|
|
73
|
+
}[] = [
|
|
74
|
+
{
|
|
75
|
+
key: 'stanceDivergence',
|
|
76
|
+
weight: 0.30,
|
|
77
|
+
direction: 'rising',
|
|
78
|
+
why: 'an unresolved split is the one state where another call can still change the verdict',
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
key: 'contradictionCount',
|
|
82
|
+
weight: 0.25,
|
|
83
|
+
direction: 'rising',
|
|
84
|
+
why: 'an explicit disagree vote names a specific thing a rebuttal can settle',
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
key: 'rankUncertainty',
|
|
88
|
+
weight: 0.20,
|
|
89
|
+
direction: 'rising',
|
|
90
|
+
why: 'tied findings mean the ranking has not resolved and one more input may break it',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
key: 'confidenceSpread',
|
|
94
|
+
weight: 0.15,
|
|
95
|
+
direction: 'rising',
|
|
96
|
+
why: 'a wide confidence range means the members disagree about how sure to be, not only about what',
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
key: 'findingOverlap',
|
|
100
|
+
weight: 0.07,
|
|
101
|
+
direction: 'falling',
|
|
102
|
+
why: 'high overlap means the members are already covering the same ground',
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
key: 'selfSimilarity',
|
|
106
|
+
weight: 0.03,
|
|
107
|
+
direction: 'falling',
|
|
108
|
+
why: 'high self-similarity is exhaustion, which 6.2 handles as a stop rather than as a gain',
|
|
109
|
+
},
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
/** `contradictionCount` is a count, not 0..1; this is its saturation point. */
|
|
113
|
+
export const CONTRADICTION_SATURATION = 5;
|
|
114
|
+
|
|
115
|
+
/** The cost half. Both figures come from the caller's own estimate. */
|
|
116
|
+
export interface CallCost {
|
|
117
|
+
/** Provider calls the option would issue. Must be > 0 to be scorable. */
|
|
118
|
+
readonly calls: number;
|
|
119
|
+
/** Estimated spend in USD. */
|
|
120
|
+
readonly costUsd: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** One scorable option, exactly as recorded. */
|
|
124
|
+
export interface NextCallInputs {
|
|
125
|
+
/** Stable identifier for the option — `cross-exam:c-001`, `round-3`, … */
|
|
126
|
+
readonly optionId: string;
|
|
127
|
+
readonly signal: DisagreementSignal;
|
|
128
|
+
/** Open adversarial triggers. Rising with gain: an open objection is unfinished argument. */
|
|
129
|
+
readonly unresolvedAdversarialTriggers: number;
|
|
130
|
+
readonly cost: CallCost;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** One term's full arithmetic, so the total can be re-derived by hand. */
|
|
134
|
+
export interface TermBreakdown {
|
|
135
|
+
readonly key: string;
|
|
136
|
+
readonly available: boolean;
|
|
137
|
+
/** The component's raw value, or `null` when unavailable. */
|
|
138
|
+
readonly raw: number | null;
|
|
139
|
+
/** Why it is unavailable, verbatim from the signal. `null` when available. */
|
|
140
|
+
readonly reason: string | null;
|
|
141
|
+
/** Raw mapped into 0..1 gain space (direction applied, count saturated). */
|
|
142
|
+
readonly normalised: number | null;
|
|
143
|
+
readonly weight: number;
|
|
144
|
+
/** `normalised * weight`, or 0 when unavailable. */
|
|
145
|
+
readonly contribution: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface NextCallScore {
|
|
149
|
+
readonly optionId: string;
|
|
150
|
+
/** 0..1 expected gain, or `null` when NO component was observable. */
|
|
151
|
+
readonly gain: number | null;
|
|
152
|
+
/** How many terms the gain rests on. 0..6. */
|
|
153
|
+
readonly basisComponents: number;
|
|
154
|
+
/** Sum of the weights of the available terms — the gain's denominator. */
|
|
155
|
+
readonly weightUsed: number;
|
|
156
|
+
/** `gain / costUsd`, or `null` when gain is null or cost is not positive. */
|
|
157
|
+
readonly gainPerCost: number | null;
|
|
158
|
+
/** `gain / calls`, or `null` on the same conditions. */
|
|
159
|
+
readonly gainPerCall: number | null;
|
|
160
|
+
readonly cost: CallCost;
|
|
161
|
+
readonly terms: readonly TermBreakdown[];
|
|
162
|
+
/** The trigger bonus applied to the gain, already included in it. */
|
|
163
|
+
readonly triggerBonus: number;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Round to {@link SCORE_PRECISION}. `-0` normalised to `0` so JSON round-trips. */
|
|
167
|
+
export function roundScore(v: number): number {
|
|
168
|
+
const f = 10 ** SCORE_PRECISION;
|
|
169
|
+
const r = Math.round(v * f) / f;
|
|
170
|
+
return Object.is(r, -0) ? 0 : r;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function _normalise(key: string, c: SignalComponent, direction: 'rising' | 'falling'): number {
|
|
174
|
+
const raw = (c as { value: number }).value;
|
|
175
|
+
// `contradictionCount` is the one count-valued component; everything else
|
|
176
|
+
// is already 0..1 by its own contract.
|
|
177
|
+
const scaled = key === 'contradictionCount' ? Math.min(1, raw / CONTRADICTION_SATURATION) : Math.min(1, Math.max(0, raw));
|
|
178
|
+
return direction === 'rising' ? scaled : 1 - scaled;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Score one option. Pure — no clock, no randomness, no file, no network.
|
|
183
|
+
*
|
|
184
|
+
* The trigger bonus is additive and capped: an unresolved adversarial trigger
|
|
185
|
+
* raises expected gain because an open objection is unfinished argument (6.2's
|
|
186
|
+
* own reasoning), but it may not by itself drive the gain to 1 — that would let
|
|
187
|
+
* one open trigger outvote every measured component.
|
|
188
|
+
*/
|
|
189
|
+
export function scoreNextCall(inputs: NextCallInputs): NextCallScore {
|
|
190
|
+
const terms: TermBreakdown[] = [];
|
|
191
|
+
let weighted = 0;
|
|
192
|
+
let weightUsed = 0;
|
|
193
|
+
let basisComponents = 0;
|
|
194
|
+
|
|
195
|
+
for (const t of GAIN_TERMS) {
|
|
196
|
+
const c = inputs.signal[t.key] as SignalComponent;
|
|
197
|
+
if (c !== undefined && c.available) {
|
|
198
|
+
const normalised = _normalise(t.key, c, t.direction);
|
|
199
|
+
const contribution = normalised * t.weight;
|
|
200
|
+
weighted += contribution;
|
|
201
|
+
weightUsed += t.weight;
|
|
202
|
+
basisComponents += 1;
|
|
203
|
+
terms.push({
|
|
204
|
+
key: t.key,
|
|
205
|
+
available: true,
|
|
206
|
+
raw: roundScore(c.value),
|
|
207
|
+
reason: null,
|
|
208
|
+
normalised: roundScore(normalised),
|
|
209
|
+
weight: t.weight,
|
|
210
|
+
contribution: roundScore(contribution),
|
|
211
|
+
});
|
|
212
|
+
} else {
|
|
213
|
+
terms.push({
|
|
214
|
+
key: t.key,
|
|
215
|
+
available: false,
|
|
216
|
+
raw: null,
|
|
217
|
+
reason: c === undefined ? 'component-absent' : c.reason,
|
|
218
|
+
normalised: null,
|
|
219
|
+
weight: t.weight,
|
|
220
|
+
contribution: 0,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const triggerBonus = roundScore(Math.min(0.2, 0.1 * Math.max(0, inputs.unresolvedAdversarialTriggers)));
|
|
226
|
+
const gain =
|
|
227
|
+
basisComponents === 0 ? null : roundScore(Math.min(1, weighted / weightUsed + triggerBonus));
|
|
228
|
+
|
|
229
|
+
const scorable = gain !== null;
|
|
230
|
+
return {
|
|
231
|
+
optionId: inputs.optionId,
|
|
232
|
+
gain,
|
|
233
|
+
basisComponents,
|
|
234
|
+
weightUsed: roundScore(weightUsed),
|
|
235
|
+
gainPerCost:
|
|
236
|
+
scorable && inputs.cost.costUsd > 0 ? roundScore((gain as number) / inputs.cost.costUsd) : null,
|
|
237
|
+
gainPerCall: scorable && inputs.cost.calls > 0 ? roundScore((gain as number) / inputs.cost.calls) : null,
|
|
238
|
+
cost: inputs.cost,
|
|
239
|
+
terms,
|
|
240
|
+
triggerBonus,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Inputs plus the score they produced — the thing 8.4 calls "the recorded inputs". */
|
|
245
|
+
export interface NextCallRecord {
|
|
246
|
+
readonly inputs: NextCallInputs;
|
|
247
|
+
readonly score: NextCallScore;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function recordNextCall(inputs: NextCallInputs): NextCallRecord {
|
|
251
|
+
return { inputs, score: scoreNextCall(inputs) };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The verify clause, executable: recompute the score from the record's OWN
|
|
256
|
+
* inputs and report whether it matches byte-for-byte under JSON.
|
|
257
|
+
*/
|
|
258
|
+
export function reproduceScore(record: NextCallRecord): { reproduced: boolean; recomputed: NextCallScore } {
|
|
259
|
+
const recomputed = scoreNextCall(record.inputs);
|
|
260
|
+
return { reproduced: JSON.stringify(recomputed) === JSON.stringify(record.score), recomputed };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Rank options by gain per USD, then by gain per call, then by id. Deterministic. */
|
|
264
|
+
export function rankByGainPerCost(scores: readonly NextCallScore[]): NextCallScore[] {
|
|
265
|
+
return [...scores].sort(
|
|
266
|
+
(a, b) =>
|
|
267
|
+
(b.gainPerCost ?? -1) - (a.gainPerCost ?? -1) ||
|
|
268
|
+
(b.gainPerCall ?? -1) - (a.gainPerCall ?? -1) ||
|
|
269
|
+
(a.optionId < b.optionId ? -1 : a.optionId > b.optionId ? 1 : 0),
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Inspectable rendering — every term's arithmetic, so the total is re-derivable by hand. */
|
|
274
|
+
export function renderNextCallScore(s: NextCallScore): string {
|
|
275
|
+
const lines = [`option ${s.optionId}`];
|
|
276
|
+
for (const t of s.terms) {
|
|
277
|
+
lines.push(
|
|
278
|
+
t.available
|
|
279
|
+
? ` ${t.key.padEnd(20)} raw ${String(t.raw).padEnd(10)} × w ${t.weight.toFixed(2)} = ${String(t.contribution)}`
|
|
280
|
+
: ` ${t.key.padEnd(20)} unavailable (${t.reason ?? '—'}) — excluded from numerator AND denominator`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
lines.push(
|
|
284
|
+
` trigger bonus ${String(s.triggerBonus)}`,
|
|
285
|
+
` weight used ${String(s.weightUsed)} over ${String(s.basisComponents)} component(s)`,
|
|
286
|
+
` gain ${s.gain === null ? 'null (nothing observable)' : String(s.gain)}`,
|
|
287
|
+
` cost ${String(s.cost.calls)} call(s), $${s.cost.costUsd.toFixed(4)}`,
|
|
288
|
+
` gain per USD ${s.gainPerCost === null ? 'null' : String(s.gainPerCost)}`,
|
|
289
|
+
` gain per call ${s.gainPerCall === null ? 'null' : String(s.gainPerCall)}`,
|
|
290
|
+
'',
|
|
291
|
+
);
|
|
292
|
+
return lines.join('\n');
|
|
293
|
+
}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Corpus assembler for the provider-recognition leakage bench.
|
|
3
|
+
*
|
|
4
|
+
* `road-to-inbox-harvest-2026-08-e-council-topology-evidence`,
|
|
5
|
+
* `blocker: leakage-bench-needs-assembler-and-design-forks`, todo item 1:
|
|
6
|
+
* `collectGuesses` and `scoreRecognition` in `provider_leakage_bench.ts` both
|
|
7
|
+
* take `items: readonly LeakageItem[]` as an INJECTED parameter, and nothing in
|
|
8
|
+
* this tree ever produced those items from real council output. That gap — not
|
|
9
|
+
* quota, and not the un-committability of the corpus — is what kept the bench
|
|
10
|
+
* at NOT RUN. This module closes it and nothing else.
|
|
11
|
+
*
|
|
12
|
+
* It reads a directory of council response records — the JSON shape
|
|
13
|
+
* `{ responses: [{ provider, model, text, error, ... }] }` written per council
|
|
14
|
+
* run — and emits one `LeakageItem` per usable response body, with the response
|
|
15
|
+
* author as ground truth.
|
|
16
|
+
*
|
|
17
|
+
* ── Why the directory is a parameter, not an import ─────────────────────────
|
|
18
|
+
* `session.ts` binds `RESPONSES_DIR` at module load against the running
|
|
19
|
+
* checkout's root (`session.ts:70-73`). Importing it would couple this loader
|
|
20
|
+
* to one root and make it untestable over a fixture tree — and the same
|
|
21
|
+
* root-binding is part of the retention defect this corpus is the subject of.
|
|
22
|
+
* So the directory arrives as `opts.responsesDir` and this module imports
|
|
23
|
+
* nothing from `session.ts`.
|
|
24
|
+
*
|
|
25
|
+
* ── What this module does NOT do ────────────────────────────────────────────
|
|
26
|
+
* It does NOT anonymise. The bodies in `LeakageCorpus.items` are RAW: whatever
|
|
27
|
+
* the provider wrote, including any self-identification in the prose. An
|
|
28
|
+
* anonymisation pass is a DECLARED OPEN DESIGN FORK in the blocker above and is
|
|
29
|
+
* not settled here, so `assembleLeakageCorpus` MUST NOT be fed to a live rater
|
|
30
|
+
* until that fork is settled — a rater shown a raw body may be reading a
|
|
31
|
+
* signature rather than a style, and the recognition rate would then measure
|
|
32
|
+
* the absence of anonymisation rather than its failure.
|
|
33
|
+
*
|
|
34
|
+
* The `anonymise` seam below exists so the settled fork has somewhere to land
|
|
35
|
+
* without changing this module's shape. Its default is `IDENTITY_ANONYMISE`,
|
|
36
|
+
* which returns its input unchanged — deliberately, and tested for it, so that
|
|
37
|
+
* "the default anonymiser" can never be mistaken for anonymisation having
|
|
38
|
+
* happened.
|
|
39
|
+
*
|
|
40
|
+
* It also does NOT decide, score, prompt, or dispatch. No CLI, no `main()`, no
|
|
41
|
+
* transport, no provider calls.
|
|
42
|
+
*
|
|
43
|
+
* ── Why nothing here deletes ────────────────────────────────────────────────
|
|
44
|
+
* This module contains no write, no `unlink` and no `rm`, and must never gain
|
|
45
|
+
* one. The corpus IS the measurement subject: pruning it destroys the
|
|
46
|
+
* instrument, and the over-retention of these bodies is itself a recorded
|
|
47
|
+
* finding rather than a mess to tidy. Retention is therefore REPORTED — both
|
|
48
|
+
* partitions, counted per family in the census — and enforced by nobody here.
|
|
49
|
+
* The caller chooses which partition it is willing to measure over.
|
|
50
|
+
*/
|
|
51
|
+
import * as crypto from 'node:crypto';
|
|
52
|
+
import * as fs from 'node:fs';
|
|
53
|
+
import * as path from 'node:path';
|
|
54
|
+
|
|
55
|
+
import type { LeakageItem } from './provider_leakage_bench.js';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The synthetic fixture's basename. Its own body states that "a live runner
|
|
59
|
+
* must refuse this file"; that refusal is implemented here rather than
|
|
60
|
+
* documented, because a recognition rate over hand-written fixture prose
|
|
61
|
+
* describes the fixture author and not a model.
|
|
62
|
+
*/
|
|
63
|
+
export const SYNTHETIC_FIXTURE_BASENAME = 'smoke-items.json';
|
|
64
|
+
|
|
65
|
+
/** Any path segment containing this marker is the synthetic bench fixture dir. */
|
|
66
|
+
export const SYNTHETIC_PATH_MARKER = 'council-provider-leakage';
|
|
67
|
+
|
|
68
|
+
/** Mirrors the declared council retention window. Reported, never applied. */
|
|
69
|
+
export const DEFAULT_RETENTION_DAYS = 7;
|
|
70
|
+
|
|
71
|
+
const MS_PER_DAY = 86_400_000;
|
|
72
|
+
|
|
73
|
+
/** Thrown when the assembler is pointed at data that cannot measure leakage. */
|
|
74
|
+
export class SyntheticCorpusRefusal extends Error {
|
|
75
|
+
readonly source: string;
|
|
76
|
+
constructor(source: string, why: string) {
|
|
77
|
+
super(`refusing synthetic corpus data: ${source} — ${why}`);
|
|
78
|
+
this.name = 'SyntheticCorpusRefusal';
|
|
79
|
+
this.source = source;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Machine-readable exclusion buckets. A dropped body always names one. */
|
|
84
|
+
export type ExclusionReason =
|
|
85
|
+
/** Not a `.json` file (the tree also holds `.md` notes and stray dirs). */
|
|
86
|
+
| 'not-json-file'
|
|
87
|
+
/** File exists but is not parseable JSON. */
|
|
88
|
+
| 'unparseable-json'
|
|
89
|
+
/** Parsed, but carries no top-level `responses` array. */
|
|
90
|
+
| 'no-responses-array'
|
|
91
|
+
/** A `responses` entry that is not an object. */
|
|
92
|
+
| 'not-an-object'
|
|
93
|
+
/** Entry has a non-falsy `error` — a failed call has no body to rate. */
|
|
94
|
+
| 'response-carried-error'
|
|
95
|
+
/** Entry has no non-empty `provider`, so there is no ground truth. */
|
|
96
|
+
| 'missing-provider'
|
|
97
|
+
/** Entry has no non-empty `text`, so there is nothing to show a rater. */
|
|
98
|
+
| 'empty-text';
|
|
99
|
+
|
|
100
|
+
/** One thing that did not become an item, and why. Never a silent drop. */
|
|
101
|
+
export interface ExcludedRecord {
|
|
102
|
+
/** Source path relative to `responsesDir`, POSIX-separated. */
|
|
103
|
+
readonly source: string;
|
|
104
|
+
/** Index within `responses`, or `null` for a whole-file exclusion. */
|
|
105
|
+
readonly index: number | null;
|
|
106
|
+
readonly reason: ExclusionReason;
|
|
107
|
+
/** One clause of detail, safe to print beside the reason. */
|
|
108
|
+
readonly detail: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A corpus item: a `LeakageItem` plus the provenance the SCORER may see and the
|
|
113
|
+
* rater may not.
|
|
114
|
+
*/
|
|
115
|
+
export interface CorpusItem extends LeakageItem {
|
|
116
|
+
/** Source path relative to `responsesDir`, POSIX-separated. */
|
|
117
|
+
readonly source_file: string;
|
|
118
|
+
readonly response_index: number;
|
|
119
|
+
/** Source file mtime in epoch ms — the only retention input. */
|
|
120
|
+
readonly mtime_ms: number;
|
|
121
|
+
/** Whether `mtime_ms` falls inside `retention_days` of `now`. Reported only. */
|
|
122
|
+
readonly within_retention: boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Per-family counts. Keys are whatever providers were actually present. */
|
|
126
|
+
export type FamilyCounts = Readonly<Record<string, number>>;
|
|
127
|
+
|
|
128
|
+
export interface CorpusCensus {
|
|
129
|
+
readonly files_scanned: number;
|
|
130
|
+
readonly files_excluded: number;
|
|
131
|
+
readonly responses_seen: number;
|
|
132
|
+
readonly items_kept: number;
|
|
133
|
+
readonly items_excluded: number;
|
|
134
|
+
/** Echoed so a published census is self-describing. */
|
|
135
|
+
readonly retention_days: number;
|
|
136
|
+
readonly now_ms: number;
|
|
137
|
+
/** Kept items whose source mtime is inside the TTL, counted per family. */
|
|
138
|
+
readonly within_retention: FamilyCounts;
|
|
139
|
+
/** Kept items whose source mtime is older than the TTL, per family. */
|
|
140
|
+
readonly over_retention: FamilyCounts;
|
|
141
|
+
readonly within_retention_total: number;
|
|
142
|
+
readonly over_retention_total: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface LeakageCorpus {
|
|
146
|
+
readonly items: readonly CorpusItem[];
|
|
147
|
+
/** Sorted distinct families actually present. Never a hardcoded list. */
|
|
148
|
+
readonly families: readonly string[];
|
|
149
|
+
readonly excluded: readonly ExcludedRecord[];
|
|
150
|
+
readonly census: CorpusCensus;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface AssembleOptions {
|
|
154
|
+
/** Directory of council response records. Walked recursively. */
|
|
155
|
+
readonly responsesDir: string;
|
|
156
|
+
/** Epoch ms treated as "now" for the retention partition. */
|
|
157
|
+
readonly now?: number;
|
|
158
|
+
readonly retentionDays?: number;
|
|
159
|
+
/**
|
|
160
|
+
* The anonymisation seam. Defaults to `IDENTITY_ANONYMISE`, i.e. NO
|
|
161
|
+
* anonymisation — see the header. Supplying one does not make this module
|
|
162
|
+
* an anonymiser; it makes the fork's decision injectable.
|
|
163
|
+
*/
|
|
164
|
+
readonly anonymise?: (text: string) => string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The default `anonymise`: returns its argument unchanged. Not anonymisation. */
|
|
168
|
+
export const IDENTITY_ANONYMISE = (text: string): string => text;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Assemble a leakage corpus from a directory of council response records.
|
|
172
|
+
*
|
|
173
|
+
* Throws `SyntheticCorpusRefusal` — never excludes-and-continues — when the
|
|
174
|
+
* directory or any file in it is synthetic bench data. The blocker's wording is
|
|
175
|
+
* that a live runner must REFUSE it, and an exclusion is not a refusal: it
|
|
176
|
+
* would let a run proceed over a silently smaller corpus.
|
|
177
|
+
*/
|
|
178
|
+
export function assembleLeakageCorpus(opts: AssembleOptions): LeakageCorpus {
|
|
179
|
+
const now = opts.now ?? Date.now();
|
|
180
|
+
const retentionDays = opts.retentionDays ?? DEFAULT_RETENTION_DAYS;
|
|
181
|
+
const anonymise = opts.anonymise ?? IDENTITY_ANONYMISE;
|
|
182
|
+
const root = opts.responsesDir;
|
|
183
|
+
|
|
184
|
+
if (_posix(path.resolve(root)).includes(SYNTHETIC_PATH_MARKER)) {
|
|
185
|
+
throw new SyntheticCorpusRefusal(
|
|
186
|
+
root,
|
|
187
|
+
`the directory path contains "${SYNTHETIC_PATH_MARKER}", the synthetic bench fixture location`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const excluded: ExcludedRecord[] = [];
|
|
192
|
+
const items: CorpusItem[] = [];
|
|
193
|
+
let filesScanned = 0;
|
|
194
|
+
let filesExcluded = 0;
|
|
195
|
+
let responsesSeen = 0;
|
|
196
|
+
|
|
197
|
+
// Sorted relative paths: two runs over the same tree emit byte-identical
|
|
198
|
+
// output, which a bench whose result gets published has to be able to claim.
|
|
199
|
+
for (const rel of _walkSorted(root)) {
|
|
200
|
+
const abs = path.join(root, rel);
|
|
201
|
+
const base = path.basename(rel);
|
|
202
|
+
|
|
203
|
+
if (base === SYNTHETIC_FIXTURE_BASENAME) {
|
|
204
|
+
throw new SyntheticCorpusRefusal(
|
|
205
|
+
rel,
|
|
206
|
+
`basename is the synthetic fixture "${SYNTHETIC_FIXTURE_BASENAME}", whose own body states a live runner must refuse it`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
if (!base.endsWith('.json')) {
|
|
210
|
+
excluded.push({ source: rel, index: null, reason: 'not-json-file', detail: 'no .json extension' });
|
|
211
|
+
filesExcluded += 1;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
filesScanned += 1;
|
|
216
|
+
let parsed: unknown;
|
|
217
|
+
try {
|
|
218
|
+
parsed = JSON.parse(fs.readFileSync(abs, 'utf8'));
|
|
219
|
+
} catch (err) {
|
|
220
|
+
excluded.push({
|
|
221
|
+
source: rel,
|
|
222
|
+
index: null,
|
|
223
|
+
reason: 'unparseable-json',
|
|
224
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
225
|
+
});
|
|
226
|
+
filesExcluded += 1;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// A truthy top-level `synthetic` key is the fixture's self-declaration.
|
|
231
|
+
// Checked after the basename guard so a renamed fixture is still caught.
|
|
232
|
+
if (_isRecord(parsed) && Boolean(parsed['synthetic'])) {
|
|
233
|
+
throw new SyntheticCorpusRefusal(rel, 'the record carries a truthy top-level "synthetic" key');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const responses = _isRecord(parsed) ? parsed['responses'] : undefined;
|
|
237
|
+
if (!Array.isArray(responses)) {
|
|
238
|
+
excluded.push({
|
|
239
|
+
source: rel,
|
|
240
|
+
index: null,
|
|
241
|
+
reason: 'no-responses-array',
|
|
242
|
+
detail: 'no top-level `responses` array',
|
|
243
|
+
});
|
|
244
|
+
filesExcluded += 1;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const mtimeMs = fs.statSync(abs).mtimeMs;
|
|
249
|
+
const withinRetention = now - mtimeMs <= retentionDays * MS_PER_DAY;
|
|
250
|
+
|
|
251
|
+
for (let i = 0; i < responses.length; i += 1) {
|
|
252
|
+
responsesSeen += 1;
|
|
253
|
+
const entry: unknown = responses[i];
|
|
254
|
+
if (!_isRecord(entry)) {
|
|
255
|
+
excluded.push({ source: rel, index: i, reason: 'not-an-object', detail: `entry is ${typeof entry}` });
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const error = entry['error'];
|
|
259
|
+
if (Boolean(error)) {
|
|
260
|
+
excluded.push({
|
|
261
|
+
source: rel,
|
|
262
|
+
index: i,
|
|
263
|
+
reason: 'response-carried-error',
|
|
264
|
+
detail: typeof error === 'string' ? error.slice(0, 120) : 'non-falsy error field',
|
|
265
|
+
});
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const provider = _nonEmptyString(entry['provider']);
|
|
269
|
+
if (provider === null) {
|
|
270
|
+
excluded.push({ source: rel, index: i, reason: 'missing-provider', detail: 'no non-empty provider' });
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const text = _nonEmptyString(entry['text']);
|
|
274
|
+
if (text === null) {
|
|
275
|
+
excluded.push({ source: rel, index: i, reason: 'empty-text', detail: 'no non-empty text' });
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
items.push({
|
|
279
|
+
id: leakageItemId(rel, i),
|
|
280
|
+
text: anonymise(text),
|
|
281
|
+
true_family: provider,
|
|
282
|
+
source_file: rel,
|
|
283
|
+
response_index: i,
|
|
284
|
+
mtime_ms: mtimeMs,
|
|
285
|
+
within_retention: withinRetention,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const within: Record<string, number> = {};
|
|
291
|
+
const over: Record<string, number> = {};
|
|
292
|
+
for (const item of items) {
|
|
293
|
+
const bucket = item.within_retention ? within : over;
|
|
294
|
+
bucket[item.true_family] = (bucket[item.true_family] ?? 0) + 1;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
items,
|
|
299
|
+
families: [...new Set(items.map((i) => i.true_family))].sort(),
|
|
300
|
+
excluded,
|
|
301
|
+
census: {
|
|
302
|
+
files_scanned: filesScanned,
|
|
303
|
+
files_excluded: filesExcluded,
|
|
304
|
+
responses_seen: responsesSeen,
|
|
305
|
+
items_kept: items.length,
|
|
306
|
+
items_excluded: excluded.filter((e) => e.index !== null).length,
|
|
307
|
+
retention_days: retentionDays,
|
|
308
|
+
now_ms: now,
|
|
309
|
+
within_retention: within,
|
|
310
|
+
over_retention: over,
|
|
311
|
+
within_retention_total: _sum(within),
|
|
312
|
+
over_retention_total: _sum(over),
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Stable, collision-free, OPAQUE item id.
|
|
319
|
+
*
|
|
320
|
+
* The id is a digest of the source path plus the response index — NOT the
|
|
321
|
+
* basename — and that is load-bearing rather than stylistic: real filenames
|
|
322
|
+
* under `agents/runtime/council/responses/` include
|
|
323
|
+
* `anthropic-design-skills-integration.json` and several `claude-*.json`, so a
|
|
324
|
+
* basename-derived id would hand the rater the ground truth in the item's own
|
|
325
|
+
* identifier and the bench would measure reading, not recognition. The id must
|
|
326
|
+
* never embed the provider name, or any part of a path that might.
|
|
327
|
+
*
|
|
328
|
+
* Deterministic (a digest, not a counter) so two runs over the same tree
|
|
329
|
+
* produce the same ids, and index-suffixed so two bodies in one file cannot
|
|
330
|
+
* collide.
|
|
331
|
+
*/
|
|
332
|
+
export function leakageItemId(relPath: string, index: number): string {
|
|
333
|
+
const digest = crypto.createHash('sha1').update(_posix(relPath)).digest('hex').slice(0, 12);
|
|
334
|
+
return `item-${digest}-${String(index)}`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Recursive file walk, returning `root`-relative POSIX paths in sorted order. */
|
|
338
|
+
function _walkSorted(root: string): string[] {
|
|
339
|
+
const out: string[] = [];
|
|
340
|
+
const visit = (dirRel: string): void => {
|
|
341
|
+
let entries: fs.Dirent[];
|
|
342
|
+
try {
|
|
343
|
+
entries = fs.readdirSync(path.join(root, dirRel), { withFileTypes: true });
|
|
344
|
+
} catch {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const names = entries.map((e) => e.name).sort();
|
|
348
|
+
for (const name of names) {
|
|
349
|
+
const rel = dirRel === '' ? name : `${dirRel}/${name}`;
|
|
350
|
+
const abs = path.join(root, rel);
|
|
351
|
+
let stat: fs.Stats;
|
|
352
|
+
try {
|
|
353
|
+
stat = fs.statSync(abs);
|
|
354
|
+
} catch {
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (stat.isDirectory()) {
|
|
358
|
+
// Real records nest: `<slug>.json/` directories hold per-round
|
|
359
|
+
// files. Recursing keeps those bodies eligible; the relative
|
|
360
|
+
// path keeps their ids distinct.
|
|
361
|
+
visit(rel);
|
|
362
|
+
} else if (stat.isFile()) {
|
|
363
|
+
out.push(rel);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
visit('');
|
|
368
|
+
return out.sort();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function _posix(p: string): string {
|
|
372
|
+
return p.split(path.sep).join('/');
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function _isRecord(v: unknown): v is Record<string, unknown> {
|
|
376
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function _nonEmptyString(v: unknown): string | null {
|
|
380
|
+
return typeof v === 'string' && v.trim() !== '' ? v : null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function _sum(counts: Readonly<Record<string, number>>): number {
|
|
384
|
+
return Object.values(counts).reduce((a, b) => a + b, 0);
|
|
385
|
+
}
|