@danceiny/gotry 0.2.0-rc.27 → 0.2.0-rc.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/gotry-bootstrap.js +56 -12
- package/data/yunnan-pack.json +30 -27
- package/dist/capabilities/anything.js +26 -0
- package/dist/capabilities/channel-registry.js +4 -4
- package/dist/capabilities/doctor.js +14 -7
- package/dist/capabilities/effect.js +1 -1
- package/dist/capabilities/ground-transfer.js +168 -1
- package/dist/capabilities/provider-detail-sanitize.js +64 -0
- package/dist/capabilities/route-provider-conformance.js +341 -0
- package/dist/capabilities/session/extension-bridge.js +3 -3
- package/dist/capabilities/session-search.js +1 -1
- package/dist/scripts/anything-path-measure-tests.js +133 -0
- package/dist/scripts/anything-path-measure.js +150 -0
- package/dist/scripts/anything-tests.js +136 -2
- package/dist/scripts/booking-copilot-dsh-warmer-proof.js +5 -2
- package/dist/scripts/bootstrap-tests.js +77 -7
- package/dist/scripts/city-scenario-tier-tests.js +234 -0
- package/dist/scripts/doctor-tests.js +39 -3
- package/dist/scripts/drill-multiuser-ledger-tests.js +1076 -0
- package/dist/scripts/drill-sdk-descendant-cleanup-tests.js +606 -0
- package/dist/scripts/drill-w2a-sensor-tests.js +788 -0
- package/dist/scripts/dsh-llm-tests.js +437 -0
- package/dist/scripts/g5-guard-tests.js +20 -1
- package/dist/scripts/g5-guard.js +5 -1
- package/dist/scripts/gotry-backend-kernel-tests.js +1 -1
- package/dist/scripts/m3-cohort-tests.js +714 -0
- package/dist/scripts/m3-cohort.js +310 -0
- package/dist/scripts/nightly-evidence-tests.js +44 -2
- package/dist/scripts/nightly-evidence.js +21 -4
- package/dist/scripts/outcome-projection-tests.js +427 -0
- package/dist/scripts/persona-sim-tests.js +621 -0
- package/dist/scripts/persona-sim.js +1081 -0
- package/dist/scripts/replay-async.js +2 -0
- package/dist/scripts/replay.js +2 -0
- package/dist/scripts/route-provider-conformance-tests.js +391 -0
- package/dist/scripts/session-dida-live-e2e.js +1 -1
- package/dist/scripts/session-zone-crash.js +49 -0
- package/dist/scripts/session-zone-ledger-tests.js +432 -0
- package/dist/scripts/session-zone-observation-tests.js +492 -0
- package/dist/scripts/session-zone-observe.js +135 -0
- package/dist/scripts/session-zone-wiring-tests.js +566 -0
- package/dist/scripts/session-zones-tests.js +17 -1
- package/dist/scripts/spawn-bounded-abort-tests.js +3 -0
- package/dist/scripts/state-cli.js +16 -0
- package/dist/scripts/time-eval-tests.js +2 -0
- package/dist/scripts/unified-tests.js +79 -2
- package/dist/scripts/z3-race-tests.js +6 -1
- package/dist/src/backend/modules/session-search.js +1 -1
- package/dist/src/city-scenario-tier.js +378 -0
- package/dist/src/dsh-llm.js +84 -14
- package/dist/src/index.js +166 -7
- package/dist/src/loop.js +16 -5
- package/dist/src/m3-cohort.js +1577 -0
- package/dist/src/outcome-projection.js +746 -0
- package/dist/src/session-zone-ledger.js +312 -0
- package/dist/src/session-zone-observation.js +354 -0
- package/dist/src/session-zone-wiring.js +658 -0
- package/dist/src/session-zones.js +7 -4
- package/dist/src/state-ledger.js +293 -40
- package/dist/src/unified.js +69 -5
- package/extension/README.md +1 -1
- package/extension/README.zh-CN.md +1 -1
- package/extension/background.js +1 -1
- package/extension/content-bridge.js +1 -1
- package/extension/content-main.js +1 -1
- package/extension/manifest.json +2 -2
- package/package.json +1 -1
- package/ts/capabilities/anything.ts +27 -0
- package/ts/capabilities/session/extension-bridge.ts +3 -3
- package/ts/capabilities/session-search.ts +1 -1
- package/ts/scripts/state-cli.ts +18 -0
- package/ts/src/contracts.ts +4 -0
- package/ts/src/dsh-llm.ts +111 -9
- package/ts/src/index.ts +181 -8
- package/ts/src/loop.ts +15 -5
- package/ts/src/state-ledger.ts +294 -40
- package/ts/src/unified.ts +91 -18
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* City × scenario tiering MECHANISM tests (issue #339 contract slice,
|
|
3
|
+
* SIMULATED-TRIGGER DRILL — fully offline, zero network, zero state).
|
|
4
|
+
*
|
|
5
|
+
* ⚠ Evidence label: `simulated_trigger_drill` / `fixture_contract`. #339's real
|
|
6
|
+
* trigger (reviewable real usage samples + an explicit scenario vocabulary and
|
|
7
|
+
* success metric) has NOT fired. Every registry used below is a TEST-ONLY shape
|
|
8
|
+
* probe constructed with `{ triggerFired: true }`; its city/scenario strings are
|
|
9
|
+
* deliberately meaningless placeholders (`city-a`, `scenario-x`) so that nothing
|
|
10
|
+
* here can be mistaken for a taxonomy. The product registry stays frozen EMPTY —
|
|
11
|
+
* #339 forbids fabricating a profile from fixtures.
|
|
12
|
+
*
|
|
13
|
+
* Sections:
|
|
14
|
+
* A. Default-off proof: trigger frozen false, registry frozen empty in SOURCE,
|
|
15
|
+
* admission refused before any validation, every resolution neutral.
|
|
16
|
+
* B. Admission shape: mandatory provenance (evidence grade / real sample source /
|
|
17
|
+
* ≥3 sample refs / reviewer / freeze instant) and retirement (condition +
|
|
18
|
+
* review deadline postdating the freeze); each omission fail-closed.
|
|
19
|
+
* C. Bounded modifier: integer ppm inside declared bounds; 0, negative,
|
|
20
|
+
* out-of-range, float and non-numeric refused — no tier can annihilate a score.
|
|
21
|
+
* D. Neutral fallback: unknown city, unknown scenario, conflicting evidence,
|
|
22
|
+
* empty registry; a conflict elects no winner.
|
|
23
|
+
* E. Ranking-only falsification: a modifier reorders but can NEVER remove a
|
|
24
|
+
* candidate (incl. lower bound × zero semantic), the result exposes no filter
|
|
25
|
+
* surface, and the mechanical no-hard-filter check passes.
|
|
26
|
+
* F. Determinism + versioning: identical inputs → byte-identical output; ties keep
|
|
27
|
+
* declaration order; a defective registry entry is refused, never clamped.
|
|
28
|
+
*
|
|
29
|
+
* Run (from ts/): npx tsx scripts/city-scenario-tier-tests.ts
|
|
30
|
+
*/
|
|
31
|
+
import { readFileSync } from 'node:fs';
|
|
32
|
+
import { join } from 'node:path';
|
|
33
|
+
import { fileURLToPath } from 'node:url';
|
|
34
|
+
import { CITY_SCENARIO_TIER_REGISTRY, CITY_SCENARIO_TIER_SCHEMA, CITY_SCENARIO_TIER_TRIGGER_FIRED, TIER_APPLICATION_SURFACES, TIER_EVIDENCE_GRADES, TIER_KEY_MAX_CHARS, TIER_MIN_SAMPLE_REFS, TIER_MODIFIER_PPM_MAX, TIER_MODIFIER_PPM_MIN, TIER_MODIFIER_PPM_NEUTRAL, TIER_NEUTRAL_REASONS, TIER_SAMPLE_SOURCES, admitTierEntry, applyTierRanking, normalizeTierKey, resolveTier, tierRankingFilterViolation, } from '../src/city-scenario-tier.js';
|
|
35
|
+
let passed = 0;
|
|
36
|
+
function check(cond, msg) {
|
|
37
|
+
if (cond) {
|
|
38
|
+
passed++;
|
|
39
|
+
console.log(` ok - ${msg}`);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
console.error(` FAIL - ${msg}`);
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function expectRefusal(result, code, needle, msg) {
|
|
47
|
+
if (result.ok) {
|
|
48
|
+
check(false, `${msg} (expected refusal ${code}, got ok)`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
check(result.code === code && result.detail.includes(needle), `${msg} (refused ${result.code}${result.code === code ? '' : ` ≠ ${code}`}, evidence contains "${needle}": ${result.detail.includes(needle)})`);
|
|
52
|
+
}
|
|
53
|
+
const FROZEN_AT = '2026-10-04T00:00:00.000Z';
|
|
54
|
+
const REVIEW_BY = '2027-04-04T00:00:00.000Z';
|
|
55
|
+
/** TEST-ONLY shape probe. The keys are meaningless placeholders, not a taxonomy. */
|
|
56
|
+
function probeEntryInput(overrides = {}) {
|
|
57
|
+
return {
|
|
58
|
+
tier_id: 'probe-tier-1',
|
|
59
|
+
city_key: 'city-a',
|
|
60
|
+
scenario_key: 'scenario-x',
|
|
61
|
+
modifier_ppm: 1_100_000,
|
|
62
|
+
applies_to: 'ranking',
|
|
63
|
+
provenance: {
|
|
64
|
+
evidence_grade: 'controlled_replay',
|
|
65
|
+
sample_source: 'real_usage_sample',
|
|
66
|
+
sample_refs: ['sample-ref-1', 'sample-ref-2', 'sample-ref-3'],
|
|
67
|
+
reviewer: 'drill-reviewer',
|
|
68
|
+
frozen_at: FROZEN_AT,
|
|
69
|
+
},
|
|
70
|
+
retirement: { condition: 'withdraw if the controlled replay no longer reproduces the ordering', review_by: REVIEW_BY },
|
|
71
|
+
taxonomy_version: 'probe.v0',
|
|
72
|
+
...overrides,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function admittedProbe(overrides = {}, registry = []) {
|
|
76
|
+
const r = admitTierEntry(probeEntryInput(overrides), { triggerFired: true, registry });
|
|
77
|
+
if (!r.ok)
|
|
78
|
+
throw new Error(`probe admission failed unexpectedly: ${r.code} ${r.detail}`);
|
|
79
|
+
return r.value;
|
|
80
|
+
}
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// A. Default-off proof
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
console.log('A. default-off: trigger frozen false, registry frozen EMPTY, nothing admitted');
|
|
85
|
+
check(CITY_SCENARIO_TIER_TRIGGER_FIRED === false, '#339 trigger gate frozen false');
|
|
86
|
+
check(CITY_SCENARIO_TIER_REGISTRY.length === 0, 'the runtime taxonomy registry is empty');
|
|
87
|
+
check(CITY_SCENARIO_TIER_SCHEMA === 'gotry_city_scenario_tier.v1', 'the taxonomy schema id is versioned');
|
|
88
|
+
const MODULE_SOURCE = readFileSync(join(fileURLToPath(new URL('.', import.meta.url)), '..', 'src', 'city-scenario-tier.ts'), 'utf8');
|
|
89
|
+
check(MODULE_SOURCE.includes('export const CITY_SCENARIO_TIER_REGISTRY: readonly CityScenarioTierEntry[] = []'), 'the registry declaration is literally `= []` in source (no taxonomy content in code)');
|
|
90
|
+
check(MODULE_SOURCE.includes('export const CITY_SCENARIO_TIER_TRIGGER_FIRED = false'), 'the trigger declaration is literally `= false` in source');
|
|
91
|
+
expectRefusal(admitTierEntry(probeEntryInput()), 'trigger_not_fired', '不凭 fixture 造画像', 'with the default trigger, admission is refused before any validation');
|
|
92
|
+
expectRefusal(admitTierEntry({ tier_id: '', city_key: '', scenario_key: '', modifier_ppm: -1, taxonomy_version: '' }), 'trigger_not_fired', '#339 trigger has not fired', 'even a wholly invalid entry is refused by the trigger gate first (pre-trigger refusal precedes validation)');
|
|
93
|
+
const defaultResolution = resolveTier({ cityKey: 'any-city', scenarioKey: 'any-scenario' });
|
|
94
|
+
check(defaultResolution.ok && defaultResolution.value.status === 'neutral' && defaultResolution.value.modifier_ppm === TIER_MODIFIER_PPM_NEUTRAL, 'with the default trigger every pair resolves to the neutral modifier ×1.0');
|
|
95
|
+
check(defaultResolution.ok && defaultResolution.value.status === 'neutral' && defaultResolution.value.reason === 'trigger_not_fired', 'the neutral reason is trigger_not_fired, stated rather than implied');
|
|
96
|
+
check(TIER_MODIFIER_PPM_NEUTRAL === 1_000_000, 'the neutral modifier is exactly ×1.0');
|
|
97
|
+
check(TIER_NEUTRAL_REASONS.length === 5 && TIER_NEUTRAL_REASONS.includes('conflicting_evidence'), 'the neutral-reason set is closed and names conflicting evidence');
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// B. Admission shape (mandatory provenance + retirement)
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
console.log('B. admission shape: provenance and retirement are mandatory per tier');
|
|
102
|
+
const admitted = admitTierEntry(probeEntryInput(), { triggerFired: true, registry: [] });
|
|
103
|
+
check(admitted.ok && admitted.value.schema === CITY_SCENARIO_TIER_SCHEMA && admitted.value.taxonomy_version === 'probe.v0', 'a complete probe entry is admitted and carries its schema + taxonomy version');
|
|
104
|
+
check(admitted.ok && admitted.value.applies_to === 'ranking' && TIER_APPLICATION_SURFACES.length === 1, 'the only application surface is `ranking` (there is no hard-filter surface)');
|
|
105
|
+
check(admitted.ok && admitted.value.provenance.sample_refs.length === 3 && admitted.value.retirement.review_by === REVIEW_BY, 'provenance sample refs and the retirement deadline are preserved');
|
|
106
|
+
expectRefusal(admitTierEntry(probeEntryInput({ provenance: undefined }), { triggerFired: true }), 'provenance_required', 'a tier without a stated evidence source is not evidence', 'missing provenance refused');
|
|
107
|
+
expectRefusal(admitTierEntry(probeEntryInput({ retirement: undefined }), { triggerFired: true }), 'retirement_required', 'a tier that cannot expire is not evidence', 'missing retirement refused');
|
|
108
|
+
expectRefusal(admitTierEntry(probeEntryInput({ provenance: { ...probeEntryInput().provenance, sample_source: 'fixture' } }), { triggerFired: true }), 'closed_set', '不凭 fixture 造画像', 'a fixture-sourced sample is refused by name (#339 red line)');
|
|
109
|
+
check(TIER_SAMPLE_SOURCES.length === 1 && TIER_SAMPLE_SOURCES[0] === 'real_usage_sample', 'the admitted sample-source set has exactly one member: real_usage_sample');
|
|
110
|
+
expectRefusal(admitTierEntry(probeEntryInput({ provenance: { ...probeEntryInput().provenance, evidence_grade: 'vibes' } }), { triggerFired: true }), 'closed_set', TIER_EVIDENCE_GRADES[0], 'an evidence grade outside the RFC S2 ordering is refused');
|
|
111
|
+
expectRefusal(admitTierEntry(probeEntryInput({ provenance: { ...probeEntryInput().provenance, sample_refs: ['only-one'] } }), { triggerFired: true }), 'provenance_required', 'a single anecdote is not a tier', `fewer than ${TIER_MIN_SAMPLE_REFS} sample refs refused`);
|
|
112
|
+
expectRefusal(admitTierEntry(probeEntryInput({ provenance: { ...probeEntryInput().provenance, reviewer: '' } }), { triggerFired: true }), 'provenance_required', 'reviewer', 'a tier with no accountable reviewer refused');
|
|
113
|
+
expectRefusal(admitTierEntry(probeEntryInput({ provenance: { ...probeEntryInput().provenance, frozen_at: '2026-10-04' } }), { triggerFired: true }), 'bad_ts', 'date-only', 'a date-only freeze instant refused');
|
|
114
|
+
expectRefusal(admitTierEntry(probeEntryInput({ retirement: { condition: '', review_by: REVIEW_BY } }), { triggerFired: true }), 'retirement_required', 'retirement.condition', 'an empty retirement condition refused');
|
|
115
|
+
expectRefusal(admitTierEntry(probeEntryInput({ retirement: { condition: 'c', review_by: FROZEN_AT } }), { triggerFired: true }), 'retirement_required', 'must postdate', 'a review deadline that does not postdate the freeze refused');
|
|
116
|
+
expectRefusal(admitTierEntry(probeEntryInput({ applies_to: 'hard_filter' }), { triggerFired: true }), 'closed_set', 'memory never enters a hard filter', 'applies_to = hard_filter refused by name');
|
|
117
|
+
expectRefusal(admitTierEntry(probeEntryInput({ taxonomy_version: '' }), { triggerFired: true }), 'binding_incomplete', 'taxonomy_version', 'an unversioned tier refused');
|
|
118
|
+
expectRefusal(admitTierEntry(probeEntryInput({ tier_id: ' ' }), { triggerFired: true }), 'binding_incomplete', 'tier_id', 'a blank tier id refused');
|
|
119
|
+
expectRefusal(admitTierEntry(probeEntryInput({ city_key: '' }), { triggerFired: true }), 'bad_key', 'must not be blank', 'a blank city key refused (blank ≠ unknown)');
|
|
120
|
+
expectRefusal(admitTierEntry(probeEntryInput({ scenario_key: 'x'.repeat(TIER_KEY_MAX_CHARS + 1) }), { triggerFired: true }), 'bad_key', 'never prose', 'an over-long scenario key refused');
|
|
121
|
+
const oneEntry = [admittedProbe()];
|
|
122
|
+
expectRefusal(admitTierEntry(probeEntryInput({ tier_id: 'probe-tier-2' }), { triggerFired: true, registry: oneEntry }), 'duplicate_tier', 'conflicting evidence, not an additional tier', 'a second tier for the same (city, scenario) pair is refused at admission');
|
|
123
|
+
expectRefusal(admitTierEntry(probeEntryInput({ city_key: 'city-b' }), { triggerFired: true, registry: oneEntry }), 'duplicate_tier', 'already exists', 'a duplicate tier_id refused');
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// C. Bounded modifier
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
console.log('C. bounded modifier: declared [min,max], lower bound > 0 by design');
|
|
128
|
+
check(TIER_MODIFIER_PPM_MIN > 0 && TIER_MODIFIER_PPM_MIN < TIER_MODIFIER_PPM_NEUTRAL && TIER_MODIFIER_PPM_MAX > TIER_MODIFIER_PPM_NEUTRAL, 'bounds straddle neutral and the lower bound is strictly positive');
|
|
129
|
+
check(admitTierEntry(probeEntryInput({ modifier_ppm: TIER_MODIFIER_PPM_MIN }), { triggerFired: true }).ok, 'the lower bound itself is admissible (inclusive)');
|
|
130
|
+
check(admitTierEntry(probeEntryInput({ modifier_ppm: TIER_MODIFIER_PPM_MAX }), { triggerFired: true }).ok, 'the upper bound itself is admissible (inclusive)');
|
|
131
|
+
expectRefusal(admitTierEntry(probeEntryInput({ modifier_ppm: 0 }), { triggerFired: true }), 'modifier_bounds', 'never annihilate a candidate', 'a zero modifier is refused (it would annihilate a candidate score)');
|
|
132
|
+
expectRefusal(admitTierEntry(probeEntryInput({ modifier_ppm: -1_000_000 }), { triggerFired: true }), 'modifier_bounds', 'outside the declared bounds', 'a negative modifier refused');
|
|
133
|
+
expectRefusal(admitTierEntry(probeEntryInput({ modifier_ppm: TIER_MODIFIER_PPM_MIN - 1 }), { triggerFired: true }), 'modifier_bounds', 'outside the declared bounds', 'one ppm below the lower bound refused');
|
|
134
|
+
expectRefusal(admitTierEntry(probeEntryInput({ modifier_ppm: TIER_MODIFIER_PPM_MAX + 1 }), { triggerFired: true }), 'modifier_bounds', 'outside the declared bounds', 'one ppm above the upper bound refused');
|
|
135
|
+
expectRefusal(admitTierEntry(probeEntryInput({ modifier_ppm: 1_100_000.5 }), { triggerFired: true }), 'modifier_bounds', 'ppm integers are exact', 'a fractional ppm refused');
|
|
136
|
+
expectRefusal(admitTierEntry(probeEntryInput({ modifier_ppm: '1100000' }), { triggerFired: true }), 'modifier_bounds', 'must be an integer ppm', 'a string modifier refused');
|
|
137
|
+
expectRefusal(admitTierEntry(probeEntryInput({ modifier_ppm: Number.NaN }), { triggerFired: true }), 'modifier_bounds', 'must be an integer ppm', 'NaN modifier refused');
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// D. Neutral fallback, never a guess
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
console.log('D. neutral fallback: unknown city / unknown scenario / conflicting evidence');
|
|
142
|
+
const probeRegistry = [admittedProbe()];
|
|
143
|
+
const hit = resolveTier({ cityKey: 'city-a', scenarioKey: 'scenario-x' }, { registry: probeRegistry, triggerFired: true });
|
|
144
|
+
check(hit.ok && hit.value.status === 'hit' && hit.value.modifier_ppm === 1_100_000, 'a known (city, scenario) pair resolves to its bounded modifier');
|
|
145
|
+
check(hit.ok && hit.value.status === 'hit' && hit.value.explanation.includes('retire when:'), 'a hit explains itself and surfaces its retirement condition');
|
|
146
|
+
check(hit.ok && hit.value.status === 'hit' && hit.value.evidence_grade === 'controlled_replay' && hit.value.taxonomy_version === 'probe.v0', 'a hit carries the evidence grade and taxonomy version');
|
|
147
|
+
const unknownCity = resolveTier({ cityKey: 'city-zz', scenarioKey: 'scenario-x' }, { registry: probeRegistry, triggerFired: true });
|
|
148
|
+
check(unknownCity.ok && unknownCity.value.status === 'neutral' && unknownCity.value.reason === 'unknown_city' && unknownCity.value.modifier_ppm === TIER_MODIFIER_PPM_NEUTRAL, 'unknown city → neutral ×1.0 with reason unknown_city');
|
|
149
|
+
check(unknownCity.ok && unknownCity.value.status === 'neutral' && unknownCity.value.explanation.includes('never guessed from a neighbour'), 'the unknown-city fallback states that no neighbour is substituted');
|
|
150
|
+
const unknownScenario = resolveTier({ cityKey: 'city-a', scenarioKey: 'scenario-zz' }, { registry: probeRegistry, triggerFired: true });
|
|
151
|
+
check(unknownScenario.ok && unknownScenario.value.status === 'neutral' && unknownScenario.value.reason === 'unknown_scenario', 'unknown scenario → neutral ×1.0 with reason unknown_scenario');
|
|
152
|
+
const emptyRegistry = resolveTier({ cityKey: 'city-a', scenarioKey: 'scenario-x' }, { registry: [], triggerFired: true });
|
|
153
|
+
check(emptyRegistry.ok && emptyRegistry.value.status === 'neutral' && emptyRegistry.value.reason === 'empty_registry', 'empty registry → neutral ×1.0, not a zero weight');
|
|
154
|
+
/** Conflicting registry built by bypassing the admission duplicate guard (a defective registry). */
|
|
155
|
+
const conflicting = [
|
|
156
|
+
admittedProbe({ tier_id: 'probe-conflict-a', modifier_ppm: 1_100_000 }),
|
|
157
|
+
admittedProbe({ tier_id: 'probe-conflict-b', modifier_ppm: 900_000 }),
|
|
158
|
+
];
|
|
159
|
+
const conflict = resolveTier({ cityKey: 'city-a', scenarioKey: 'scenario-x' }, { registry: conflicting, triggerFired: true });
|
|
160
|
+
check(conflict.ok && conflict.value.status === 'neutral' && conflict.value.reason === 'conflicting_evidence' && conflict.value.modifier_ppm === TIER_MODIFIER_PPM_NEUTRAL, 'conflicting evidence → neutral ×1.0, no winner elected');
|
|
161
|
+
check(conflict.ok && conflict.value.status === 'neutral' && conflict.value.conflicting_tier_ids.join(',') === 'probe-conflict-a,probe-conflict-b', 'the conflict surfaces both tier ids for diagnosis (without choosing)');
|
|
162
|
+
check(conflict.ok && conflict.value.tier_id === null, 'a neutral resolution never reports a tier id');
|
|
163
|
+
check(normalizeTierKey(' City-A ', 'k').ok, 'keys normalize: trim + lowercase');
|
|
164
|
+
const n1 = normalizeTierKey(' City A ', 'k');
|
|
165
|
+
const n2 = normalizeTierKey('city a', 'k');
|
|
166
|
+
check(n1.ok && n2.ok && n1.value === n2.value, 'whitespace collapse + case folding are deterministic and agree');
|
|
167
|
+
const caseHit = resolveTier({ cityKey: 'CITY-A', scenarioKey: ' Scenario-X ' }, { registry: probeRegistry, triggerFired: true });
|
|
168
|
+
check(caseHit.ok && caseHit.value.status === 'hit', 'a case/whitespace variant of a known key still hits (normalization, not fuzzy matching)');
|
|
169
|
+
expectRefusal(resolveTier({ cityKey: ' ', scenarioKey: 'scenario-x' }, { registry: probeRegistry, triggerFired: true }), 'bad_key', 'must not be blank', 'a blank city key is a caller error, not a silent neutral');
|
|
170
|
+
expectRefusal(resolveTier({ cityKey: 42, scenarioKey: 'scenario-x' }, { registry: probeRegistry, triggerFired: true }), 'bad_key', 'must be a string', 'a non-string key is a caller error');
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// E. Ranking-only falsification
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
console.log('E. FALSIFICATION: a modifier may reorder, it can NEVER remove a candidate');
|
|
175
|
+
const candidates = [
|
|
176
|
+
{ id: 'cand-boosted', cityKey: 'city-a', semantic: 0.50 },
|
|
177
|
+
{ id: 'cand-plain-1', cityKey: 'city-b', semantic: 0.54 },
|
|
178
|
+
{ id: 'cand-plain-2', cityKey: 'city-c', semantic: 0.10 },
|
|
179
|
+
{ id: 'cand-zero', cityKey: 'city-a', semantic: 0 },
|
|
180
|
+
];
|
|
181
|
+
const neutralRank = applyTierRanking(candidates, { scenarioKey: 'scenario-x' });
|
|
182
|
+
check(neutralRank.ok && neutralRank.value.length === candidates.length, 'default (trigger off): all candidates survive');
|
|
183
|
+
check(neutralRank.ok && neutralRank.value.map(r => r.id).join(',') === 'cand-plain-1,cand-boosted,cand-plain-2,cand-zero', 'default ordering is pure semantic order (every modifier neutral)');
|
|
184
|
+
check(neutralRank.ok && neutralRank.value.every(r => r.modifier_ppm === TIER_MODIFIER_PPM_NEUTRAL && r.tier_id === null), 'default: every row carries the neutral modifier and no tier id');
|
|
185
|
+
check(neutralRank.ok && tierRankingFilterViolation(candidates, neutralRank.value) === null, 'the mechanical no-hard-filter check passes for the default ranking');
|
|
186
|
+
const boostedRank = applyTierRanking(candidates, { scenarioKey: 'scenario-x' }, { registry: probeRegistry, triggerFired: true });
|
|
187
|
+
check(boostedRank.ok && boostedRank.value.map(r => r.id).join(',') === 'cand-boosted,cand-plain-1,cand-plain-2,cand-zero', 'a ×1.1 tier REORDERS: 0.50×1.1 = 0.55 overtakes the plain 0.54');
|
|
188
|
+
check(boostedRank.ok && boostedRank.value.length === candidates.length, 'reordering preserves the candidate count exactly');
|
|
189
|
+
check(boostedRank.ok && tierRankingFilterViolation(candidates, boostedRank.value) === null, 'the mechanical no-hard-filter check passes for the reordered ranking');
|
|
190
|
+
check(boostedRank.ok && boostedRank.value.every(r => candidates.some(c => c.id === r.id && c.semantic === r.semantic)), 'every row preserves its original semantic score (the unmodified ranking is always recoverable)');
|
|
191
|
+
check(boostedRank.ok && (boostedRank.value.find(r => r.id === 'cand-boosted')?.explanation ?? '').includes('probe-tier-1'), 'the applied bias is disclosed per candidate (ranking-disclosure precedent)');
|
|
192
|
+
/** The most hostile admissible tier: the lower bound applied to a zero semantic score. */
|
|
193
|
+
const minTier = [admittedProbe({ tier_id: 'probe-min', modifier_ppm: TIER_MODIFIER_PPM_MIN })];
|
|
194
|
+
const minRank = applyTierRanking(candidates, { scenarioKey: 'scenario-x' }, { registry: minTier, triggerFired: true });
|
|
195
|
+
check(minRank.ok && minRank.value.length === candidates.length, 'FALSIFICATION: even the lower-bound modifier removes nobody (count unchanged)');
|
|
196
|
+
check(minRank.ok && minRank.value.some(r => r.id === 'cand-zero' && r.score === 0), 'FALSIFICATION: a zero-semantic candidate scores 0 and is still PRESENT (score 0 ≠ removal)');
|
|
197
|
+
check(minRank.ok && tierRankingFilterViolation(candidates, minRank.value) === null, 'FALSIFICATION: the no-hard-filter check passes at the hostile bound');
|
|
198
|
+
check(minRank.ok && !Object.keys(minRank.value[0]).some(k => /exclud|filter|drop|reject|threshold/i.test(k)), 'the ranked row shape exposes no exclude/filter/drop/threshold field (no filter surface exists)');
|
|
199
|
+
let everPresent = true;
|
|
200
|
+
for (let semantic = 0; semantic <= 10; semantic++) {
|
|
201
|
+
for (const registry of [[], probeRegistry, minTier]) {
|
|
202
|
+
const probe = applyTierRanking([{ id: 'solo', cityKey: 'city-a', semantic: semantic / 10 }], { scenarioKey: 'scenario-x' }, { registry, triggerFired: true });
|
|
203
|
+
if (!probe.ok || probe.value.length !== 1 || probe.value[0].id !== 'solo')
|
|
204
|
+
everPresent = false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
check(everPresent, 'FALSIFICATION battery: across 11 semantic scores × 3 registries the candidate is never dropped');
|
|
208
|
+
expectRefusal(applyTierRanking([{ id: '', cityKey: 'city-a', semantic: 1 }], { scenarioKey: 'scenario-x' }), 'bad_candidate', 'non-empty id', 'a blank candidate id refused');
|
|
209
|
+
expectRefusal(applyTierRanking([{ id: 'a', cityKey: 'city-a', semantic: Number.NaN }], { scenarioKey: 'scenario-x' }), 'bad_candidate', 'must be finite', 'a non-finite semantic score refused');
|
|
210
|
+
expectRefusal(applyTierRanking([{ id: 'a', cityKey: 'city-a', semantic: -0.5 }], { scenarioKey: 'scenario-x' }), 'bad_candidate', 'would invert every modifier', 'a NEGATIVE semantic score is refused: the contract is multiplicative, so a boost would demote it');
|
|
211
|
+
expectRefusal(applyTierRanking([{ id: 'ok', cityKey: 'city-b', semantic: 0.5 }, { id: 'neg', cityKey: 'city-a', semantic: -1 }], { scenarioKey: 'scenario-x' }, { registry: probeRegistry, triggerFired: true }), 'bad_candidate', 'must be non-negative', 'one negative candidate refuses the whole ranking (no partial ordering over inverted semantics)');
|
|
212
|
+
check(applyTierRanking([{ id: 'zero', cityKey: 'city-a', semantic: 0 }], { scenarioKey: 'scenario-x' }, { registry: probeRegistry, triggerFired: true }).ok, 'zero is admissible (it is the boundary, not a negative)');
|
|
213
|
+
check(applyTierRanking([{ id: 'negzero', cityKey: 'city-a', semantic: -0 }], { scenarioKey: 'scenario-x' }).ok, 'negative zero is admissible (it equals zero, not below it)');
|
|
214
|
+
expectRefusal(applyTierRanking([{ id: 'a', cityKey: 'city-a', semantic: 1 }, { id: 'a', cityKey: 'city-b', semantic: 2 }], { scenarioKey: 'scenario-x' }), 'bad_candidate', 'duplicate candidate id', 'duplicate candidate ids refused (ranking inputs are a set)');
|
|
215
|
+
check(tierRankingFilterViolation(candidates, []) !== null, 'the mechanical check CATCHES a dropped-candidate violation (the check itself is falsifiable)');
|
|
216
|
+
check(tierRankingFilterViolation(candidates, (neutralRank.ok ? neutralRank.value : []).slice(0, 3)) !== null, 'the mechanical check catches a partial drop');
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// F. Determinism + versioning
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
console.log('F. determinism, stable ties, defective registry refused');
|
|
221
|
+
const runA = applyTierRanking(candidates, { scenarioKey: 'scenario-x' }, { registry: probeRegistry, triggerFired: true });
|
|
222
|
+
const runB = applyTierRanking(candidates, { scenarioKey: 'scenario-x' }, { registry: probeRegistry, triggerFired: true });
|
|
223
|
+
check(runA.ok && runB.ok && JSON.stringify(runA.value) === JSON.stringify(runB.value), 'identical inputs produce byte-identical output (deterministic)');
|
|
224
|
+
const ties = [
|
|
225
|
+
{ id: 'tie-1', cityKey: 'city-b', semantic: 0.4 },
|
|
226
|
+
{ id: 'tie-2', cityKey: 'city-c', semantic: 0.4 },
|
|
227
|
+
{ id: 'tie-3', cityKey: 'city-d', semantic: 0.4 },
|
|
228
|
+
];
|
|
229
|
+
const tieRank = applyTierRanking(ties, { scenarioKey: 'scenario-x' }, { registry: probeRegistry, triggerFired: true });
|
|
230
|
+
check(tieRank.ok && tieRank.value.map(r => r.id).join(',') === 'tie-1,tie-2,tie-3', 'ties keep declaration order (stable sort)');
|
|
231
|
+
const defective = [{ ...admittedProbe(), modifier_ppm: 5_000_000 }];
|
|
232
|
+
expectRefusal(resolveTier({ cityKey: 'city-a', scenarioKey: 'scenario-x' }, { registry: defective, triggerFired: true }), 'modifier_bounds', 'never clamped silently', 'a defective registry entry is refused at read time, never clamped silently');
|
|
233
|
+
expectRefusal(applyTierRanking(candidates, { scenarioKey: 'scenario-x' }, { registry: defective, triggerFired: true }), 'modifier_bounds', 'defective registry entry', 'ranking propagates the defective-registry refusal instead of degrading');
|
|
234
|
+
console.log(`\nCITY-SCENARIO TIER MECHANISM TESTS (#339, simulated_trigger_drill / fixture_contract; registry stays EMPTY): ${passed} pass${process.exitCode ? ', FAIL' : ' (all green)'}`);
|
|
@@ -15,15 +15,17 @@
|
|
|
15
15
|
* 11. rejected/cancelled + unavailable 通道:零执行 + 结构化 verdict='approval-denied'
|
|
16
16
|
* 12. user-action 项绝不安装;scope 过滤只考虑 items 范围内 id
|
|
17
17
|
* 13. tool-level gotry_doctor 注册 + observable chain(diagnose → repair)
|
|
18
|
+
* 14. hbcli whoami 三档解析;14b. probeStdout 在 stdio 关闭(close)后结算,exit 先于 data 不丢输出
|
|
18
19
|
*
|
|
19
20
|
* 运行: cd ts && npx tsx scripts/doctor-tests.ts
|
|
20
21
|
*/
|
|
21
22
|
import assert from 'node:assert/strict';
|
|
23
|
+
import { EventEmitter } from 'node:events';
|
|
22
24
|
import { existsSync } from 'node:fs';
|
|
23
25
|
import { mkdtemp, mkdir, writeFile, rm, readFile } from 'node:fs/promises';
|
|
24
26
|
import { tmpdir } from 'node:os';
|
|
25
27
|
import { dirname, join } from 'node:path';
|
|
26
|
-
import { runDoctorChecks, renderDoctorReportMd, nodeOk, scopeKeyFor, createRepairApprovalGate, runDoctorRepair } from '../capabilities/doctor.js';
|
|
28
|
+
import { runDoctorChecks, renderDoctorReportMd, nodeOk, probeStdout, scopeKeyFor, createRepairApprovalGate, runDoctorRepair } from '../capabilities/doctor.js';
|
|
27
29
|
import { endpointFingerprint, maskFlyaiKey, resolveFlyaiEndpoint, sha256Hex, writeFlyaiVerification } from '../capabilities/flyai-config.js';
|
|
28
30
|
import { apply } from '../src/index.js';
|
|
29
31
|
const tmp = await mkdtemp(join(tmpdir(), 'gotry-doctor-test-'));
|
|
@@ -181,7 +183,7 @@ console.log('6. gotry_doctor 工具面 + 报告落盘 OK');
|
|
|
181
183
|
// 合成 doctor items(与 bootstrap-tests §12 onboardingItems 同形态,稳定 id 锚点)。
|
|
182
184
|
const repairItems = [
|
|
183
185
|
{ id: 'node', label: 'Node 运行时', status: 'ok', detail: 'Node 22.x' },
|
|
184
|
-
{ id: 'extension', label: '
|
|
186
|
+
{ id: 'extension', label: 'Stai Travel Bridge 扩展', status: 'missing', detail: '未安装', fix: 'https://chromewebstore...' },
|
|
185
187
|
{ id: 'agent-reach', label: 'Agent Reach(网页/社媒读取)', status: 'missing', detail: '未装配', fix: 'npx @danceiny/gotry doctor --fix' },
|
|
186
188
|
{ id: 'hbcli', label: 'hbcli(酒店实时源)', status: 'missing', detail: '未安装', fix: 'npx @danceiny/gotry doctor --fix' },
|
|
187
189
|
{ id: 'flyai', label: 'FlyAI(飞猪官方检索)', status: 'degraded', detail: '未配 FLYAI_API_KEY', fix: '到 flyai 控制台申请 key' },
|
|
@@ -207,7 +209,7 @@ function fakeBridge(env = {}) {
|
|
|
207
209
|
return i.level === 'degraded' ? 'user-action' : (e.GOTRY_SETUP_HBCLI === '0' ? 'unavailable' : 'auto');
|
|
208
210
|
if (label.startsWith('dsh-better-sidebar'))
|
|
209
211
|
return e.GOTRY_SETUP_SIDEBAR === '0' ? 'unavailable' : 'auto';
|
|
210
|
-
if (label.startsWith('
|
|
212
|
+
if (label.startsWith('Stai Travel Bridge'))
|
|
211
213
|
return 'user-action';
|
|
212
214
|
if (label.startsWith('FlyAI'))
|
|
213
215
|
return 'user-action';
|
|
@@ -561,4 +563,38 @@ console.log('13. tool-level gotry_doctor 注册 + observable chain(diagnose→re
|
|
|
561
563
|
}
|
|
562
564
|
}
|
|
563
565
|
console.log('14. hbcli whoami 三档解析(未配置=degraded+注册指引 / customer 档=ok)OK');
|
|
566
|
+
// 14b. probeStdout 必须等 stdio 关闭('close')再结算,不能在 'exit' 结算:
|
|
567
|
+
// Node 文档明言 'exit' 触发时 stdout 可能还没读完。秒退的 whoami 子进程若 exit 先于 data,
|
|
568
|
+
// 输出丢成空串,空串走「不可解析=保守视为有效」分支,把无凭证 hbcli 报成 ok
|
|
569
|
+
// (Node 22 CI 偶发红 14 的 degraded 断言,与被测 PR 无关)。注入假子进程,确定性复现该次序。
|
|
570
|
+
{
|
|
571
|
+
const fakeSpawn = (script) => (() => {
|
|
572
|
+
const child = new EventEmitter();
|
|
573
|
+
child.stdout = new EventEmitter();
|
|
574
|
+
child.kill = () => true;
|
|
575
|
+
setImmediate(() => script(child, child.stdout));
|
|
576
|
+
return child;
|
|
577
|
+
});
|
|
578
|
+
const whoami = '{"env":"uat","api_key":{"configured":false},"portal":{"configured":false},"customer":{"configured":true}}';
|
|
579
|
+
const probe = (impl, timeoutMs = 1_000) => probeStdout('hbcli', ['--json', 'auth', 'whoami'], timeoutMs, impl);
|
|
580
|
+
assert.equal(await probe(fakeSpawn((child, stdout) => {
|
|
581
|
+
child.emit('exit', 0, null); // exit 先到,管道数据尚未送达
|
|
582
|
+
stdout.emit('data', Buffer.from(`${whoami}\n`));
|
|
583
|
+
child.emit('close', 0, null); // stdio 关闭后才 close
|
|
584
|
+
})), whoami, 'exit 先于 stdout data 时,输出必须完整返回(旧实现在 exit 结算,得到空串)');
|
|
585
|
+
assert.equal(await probe(fakeSpawn((child, stdout) => {
|
|
586
|
+
stdout.emit('data', Buffer.from('{"a":'));
|
|
587
|
+
stdout.emit('data', Buffer.from('1}\n'));
|
|
588
|
+
child.emit('exit', 0, null);
|
|
589
|
+
child.emit('close', 0, null);
|
|
590
|
+
})), '{"a":1}', '多段 data 拼接后 trim');
|
|
591
|
+
assert.equal(await probe(fakeSpawn((child, stdout) => {
|
|
592
|
+
child.emit('exit', 2, null);
|
|
593
|
+
stdout.emit('data', Buffer.from('boom'));
|
|
594
|
+
child.emit('close', 2, null);
|
|
595
|
+
})), null, '非零退出码一律 null(无论输出)');
|
|
596
|
+
assert.equal(await probe(fakeSpawn((child) => { child.emit('error', new Error('spawn ENOENT')); })), null, 'spawn error → null');
|
|
597
|
+
assert.equal(await probe(fakeSpawn(() => { }), 50), null, '子进程不结束 → 超时 null,不悬挂');
|
|
598
|
+
}
|
|
599
|
+
console.log('14b. probeStdout 在 stdio 关闭后结算(exit 先于 data 不丢输出 / 非零退出 null / error null / 超时 null)OK');
|
|
564
600
|
console.log('doctor-tests: 全部通过');
|