@mindrian_os/install 1.13.0-beta.17 → 1.13.0-beta.19
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +26 -0
- package/commands/act.md +1 -0
- package/commands/admin.md +1 -0
- package/commands/analyze-needs.md +2 -0
- package/commands/analyze-systems.md +2 -0
- package/commands/analyze-timing.md +2 -0
- package/commands/auto-explore.md +2 -0
- package/commands/beautiful-question.md +2 -0
- package/commands/brain-derive.md +2 -0
- package/commands/build-knowledge.md +2 -0
- package/commands/build-thesis.md +2 -0
- package/commands/causal.md +2 -0
- package/commands/challenge-assumptions.md +2 -0
- package/commands/compare-ventures.md +2 -0
- package/commands/dashboard.md +2 -1
- package/commands/deep-grade.md +2 -0
- package/commands/diagnose.md +21 -1
- package/commands/diagnostics.md +14 -3
- package/commands/doctor.md +4 -1
- package/commands/dogfood-flush.md +92 -0
- package/commands/dominant-designs.md +2 -0
- package/commands/explain-decision.md +2 -0
- package/commands/explore-domains.md +2 -0
- package/commands/explore-futures.md +2 -0
- package/commands/explore-trends.md +2 -0
- package/commands/export.md +1 -0
- package/commands/feynman-timeline-refresh.md +2 -0
- package/commands/file-meeting.md +2 -0
- package/commands/find-analogies.md +1 -0
- package/commands/find-bottlenecks.md +2 -0
- package/commands/find-connections.md +2 -0
- package/commands/funding.md +1 -0
- package/commands/grade.md +2 -0
- package/commands/graph.md +1 -0
- package/commands/hat-briefing.md +1 -0
- package/commands/heal.md +22 -170
- package/commands/help.md +54 -334
- package/commands/hmi-status.md +23 -144
- package/commands/jtbd.md +1 -0
- package/commands/leadership.md +2 -0
- package/commands/lean-canvas.md +2 -0
- package/commands/macro-trends.md +2 -0
- package/commands/map-unknowns.md +2 -0
- package/commands/memory.md +1 -0
- package/commands/models.md +1 -0
- package/commands/mos-reason.md +2 -0
- package/commands/mos.md +139 -0
- package/commands/mullins.md +2 -0
- package/commands/mva-brief.md +2 -0
- package/commands/mva-option.md +2 -0
- package/commands/new-project.md +2 -0
- package/commands/onboard.md +20 -7
- package/commands/operator.md +1 -0
- package/commands/opportunities.md +1 -0
- package/commands/organize.md +22 -469
- package/commands/persona.md +1 -0
- package/commands/pipeline.md +2 -0
- package/commands/present.md +1 -0
- package/commands/publish.md +2 -0
- package/commands/query.md +24 -102
- package/commands/radar.md +2 -0
- package/commands/reanalyze.md +1 -0
- package/commands/research.md +2 -0
- package/commands/room.md +2 -0
- package/commands/rooms.md +1 -0
- package/commands/root-cause.md +2 -0
- package/commands/rs-experts.md +1 -0
- package/commands/rs-explain.md +1 -0
- package/commands/rs-fetch.md +1 -0
- package/commands/rs-thesis.md +1 -0
- package/commands/scenario-plan.md +2 -0
- package/commands/scheduled-tasks.md +1 -0
- package/commands/score-innovation.md +2 -0
- package/commands/scout.md +1 -0
- package/commands/setup.md +2 -0
- package/commands/snapshot.md +2 -0
- package/commands/speakers.md +1 -0
- package/commands/splash.md +5 -2
- package/commands/status.md +1 -0
- package/commands/structure-argument.md +2 -0
- package/commands/suggest-next.md +2 -0
- package/commands/systems-thinking.md +2 -0
- package/commands/think-hats.md +2 -0
- package/commands/update.md +2 -0
- package/commands/user-needs.md +2 -0
- package/commands/validate.md +2 -0
- package/commands/value-proposition.md +2 -0
- package/commands/vault.md +2 -0
- package/commands/visualize.md +24 -29
- package/commands/whitespace.md +2 -1
- package/commands/wiki.md +1 -0
- package/hooks/hooks.json +22 -88
- package/lib/agents/auto-explore-agent.cjs +82 -0
- package/lib/core/breakthrough/canary.cjs +134 -0
- package/lib/core/breakthrough/canary.test.cjs +136 -0
- package/lib/core/breakthrough/detectors.cjs +359 -0
- package/lib/core/breakthrough/detectors.test.cjs +333 -0
- package/lib/core/breakthrough/ethics-fence.cjs +127 -0
- package/lib/core/breakthrough/ethics-fence.test.cjs +178 -0
- package/lib/core/breakthrough/resurfacing.cjs +150 -0
- package/lib/core/breakthrough/resurfacing.test.cjs +233 -0
- package/lib/core/breakthrough/review-queue.cjs +154 -0
- package/lib/core/breakthrough/review-queue.test.cjs +160 -0
- package/lib/core/breakthrough/scanner-d17-d18.test.cjs +229 -0
- package/lib/core/breakthrough/scanner.cjs +426 -0
- package/lib/core/breakthrough/scanner.test.cjs +267 -0
- package/lib/core/breakthrough/schema.cjs +164 -0
- package/lib/core/breakthrough/schema.test.cjs +256 -0
- package/lib/core/breakthrough/scoring.cjs +293 -0
- package/lib/core/breakthrough/scoring.test.cjs +423 -0
- package/lib/core/breakthrough/verb-dispatch.cjs +221 -0
- package/lib/core/breakthrough/verb-dispatch.test.cjs +185 -0
- package/lib/core/breakthrough/voice-scaffold.cjs +247 -0
- package/lib/core/breakthrough/voice-scaffold.test.cjs +251 -0
- package/lib/core/first-touch-version-stamper.cjs +113 -0
- package/lib/core/larry-thinness-acknowledgment.cjs +64 -0
- package/lib/core/larry-thinness-acknowledgment.test.cjs +97 -0
- package/lib/core/llm-name-suggester.cjs +194 -0
- package/lib/core/llm-name-suggester.test.cjs +132 -0
- package/lib/core/mva-orchestrator.cjs +41 -0
- package/lib/core/mva-telemetry.cjs +31 -143
- package/lib/core/navigation/edges.cjs +35 -0
- package/lib/core/navigation/memory-events.cjs +126 -0
- package/lib/core/room-auto-create.cjs +318 -0
- package/lib/core/room-auto-create.test.cjs +198 -0
- package/lib/core/room-discard-cascade.cjs +225 -0
- package/lib/core/room-discard-cascade.test.cjs +135 -0
- package/lib/core/room-name-validator.cjs +132 -0
- package/lib/core/room-name-validator.test.cjs +156 -0
- package/lib/core/room-naming-selector.cjs +357 -0
- package/lib/core/room-naming-selector.test.cjs +277 -0
- package/lib/core/room-receipt-emit.cjs +63 -0
- package/lib/core/room-skeleton-scaffold.cjs +315 -0
- package/lib/core/room-skeleton-scaffold.test.cjs +291 -0
- package/lib/core/stale-copy-scanner.cjs +190 -0
- package/lib/core/state-aware-router.cjs +78 -0
- package/lib/core/telemetry/schema.cjs +168 -0
- package/lib/core/telemetry/schema.test.cjs +124 -0
- package/lib/core/telemetry/validator.cjs +197 -0
- package/lib/core/telemetry/validator.test.cjs +188 -0
- package/lib/core/telemetry/writer.cjs +141 -0
- package/lib/core/telemetry/writer.test.cjs +331 -0
- package/lib/core/terminal-capability.cjs +88 -0
- package/lib/core/venture-shape-nudge.cjs +163 -0
- package/lib/core/venture-shape-nudge.test.cjs +161 -0
- package/lib/core/visual-ops.cjs +70 -2
- package/lib/hmi/selector-dispatcher.cjs +90 -1
- package/lib/hmi/shape-f7-breakthrough-renderer.cjs +222 -0
- package/lib/hmi/shape-f7-breakthrough-renderer.test.cjs +233 -0
- package/lib/memory/body-shape-coverage.test.cjs +268 -0
- package/lib/memory/doctor-deprecation-surface.test.cjs +185 -0
- package/lib/memory/first-touch-version.test.cjs +198 -0
- package/lib/memory/help-coverage.test.cjs +108 -0
- package/lib/memory/help-renderer.test.cjs +145 -0
- package/lib/memory/palette-consistency.test.cjs +127 -0
- package/lib/memory/pending-tension-store.cjs +80 -0
- package/lib/memory/render-v2-disposition.test.cjs +199 -0
- package/lib/memory/run-feynman-tests.cjs +213 -0
- package/lib/memory/sessionstart-coordinator.test.cjs +446 -0
- package/lib/memory/skill-vs-code-drift.test.cjs +257 -0
- package/lib/memory/soft-alias.test.cjs +144 -0
- package/lib/memory/stale-copy-scanner.test.cjs +291 -0
- package/lib/memory/state-aware-router.test.cjs +90 -0
- package/lib/memory/statusline-two-row.test.cjs +338 -0
- package/lib/memory/terminal-capability.test.cjs +155 -0
- package/lib/render/ROOM.md +74 -22
- package/lib/sessionstart/budget-compressor.cjs +130 -0
- package/lib/sessionstart/contributor-interface.cjs +134 -0
- package/lib/sessionstart/contributor-isolator.cjs +128 -0
- package/lib/sessionstart/precedence-ladder.cjs +47 -0
- package/lib/statusline/governing-thought-truncator.cjs +45 -0
- package/lib/statusline/two-row-renderer.cjs +186 -0
- package/lib/statusline/version-resolver.cjs +81 -0
- package/package.json +1 -1
- package/references/visual/ROOM.md +55 -0
- package/references/visual/palette.json +54 -0
- package/skills/larry-personality/SKILL.md +34 -0
- package/skills/ui-system/SKILL.md +109 -1
- package/skills/ui-system/rules/dual-palette.md +156 -0
- package/skills/ui-system/rules/glyph-disambiguation.md +171 -0
- package/skills/ui-system/rules/shape-f-zero-and-six.md +169 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Phase 120-01 Wave 2 Task 3 -- the 5-component breakthrough scoring formula +
|
|
5
|
+
* rankBreakthroughs + pickTopWithAffordance + getUserEngagementPrior +
|
|
6
|
+
* isThrottledKind test surface.
|
|
7
|
+
*
|
|
8
|
+
* 14+ tests covering:
|
|
9
|
+
* 1. SCORING_WEIGHTS verbatim lock + Object.freeze invariant + sum==1.0
|
|
10
|
+
* 2. RECENCY_HALF_LIFE_DAYS verbatim (== 3)
|
|
11
|
+
* 3. scoreBreakthrough determinism (literal expected score reproducible to 1e-9)
|
|
12
|
+
* 4. recency_decay half-life curve (0d/3d/6d -> 1.0 / exp(-1) / exp(-2))
|
|
13
|
+
* 5. artifact_count_log clamped to [0,1] (1->0, 10->1, 100->1 not log10(100)=2)
|
|
14
|
+
* 6. getUserEngagementPrior empty history -> neutral 0.5
|
|
15
|
+
* 7. getUserEngagementPrior confirms-only -> Laplace-smoothed 0.75 for 3 confirms
|
|
16
|
+
* 8. getUserEngagementPrior dismiss-heavy -> Laplace-smoothed 0.25 for 1 confirm + 4 dismissed
|
|
17
|
+
* 9. rankBreakthroughs ordering (stable; score desc; ties by detected_at desc)
|
|
18
|
+
* 10. pickTopWithAffordance happy / empty / single-input shapes
|
|
19
|
+
* 11. D-19 isThrottledKind dismissal-rate canary (per-detector 30% threshold)
|
|
20
|
+
* 12. Canon Part 8 source-grep (zero brain-client require + zero brain.mindrian fetch)
|
|
21
|
+
* 13. em-dash invariant (HARD RULE: zero U+2014 chars in scoring.cjs)
|
|
22
|
+
* 14. D19 constants verbatim (D19_DISMISSAL_THRESHOLD = 0.30; D19_FIRE_WINDOW = 100; D19_MIN_SAMPLE)
|
|
23
|
+
* 15. scoreBreakthrough graceful-degradation (missing fields default to 0; never throws)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const test = require('node:test');
|
|
27
|
+
const { strict: assert } = require('node:assert');
|
|
28
|
+
const fs = require('node:fs');
|
|
29
|
+
const os = require('node:os');
|
|
30
|
+
const path = require('node:path');
|
|
31
|
+
const crypto = require('node:crypto');
|
|
32
|
+
|
|
33
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
34
|
+
const scoring = require(path.join(REPO_ROOT, 'lib', 'core', 'breakthrough', 'scoring.cjs'));
|
|
35
|
+
const { openRoomDb } = require(path.join(REPO_ROOT, 'lib', 'core', 'room-db.cjs'));
|
|
36
|
+
const navigation = require(path.join(REPO_ROOT, 'lib', 'core', 'navigation.cjs'));
|
|
37
|
+
|
|
38
|
+
function makeTmpDb(prefix) {
|
|
39
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
40
|
+
fs.mkdirSync(path.join(dir, '.mindrian'), { recursive: true });
|
|
41
|
+
const db = openRoomDb(dir);
|
|
42
|
+
return { dir, db };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Seed a memory_event row directly via the navigation chokepoint (logMemoryEvent).
|
|
46
|
+
// This mirrors the real surface-time write path that Plan 120-02 scanner will use
|
|
47
|
+
// when emitting breakthrough_confirmed / breakthrough_dismissed / breakthrough_surfaced.
|
|
48
|
+
function seedMemoryEvent(db, eventType, kind, breakthroughId) {
|
|
49
|
+
const r = navigation.logMemoryEvent(db, eventType, {
|
|
50
|
+
target_node_id: breakthroughId || ('bk:' + crypto.randomBytes(4).toString('hex')),
|
|
51
|
+
kind: kind,
|
|
52
|
+
breakthrough_id: breakthroughId || ('bk:' + crypto.randomBytes(4).toString('hex')),
|
|
53
|
+
created_by: 'system',
|
|
54
|
+
source_path: 'test:scoring',
|
|
55
|
+
});
|
|
56
|
+
assert.equal(r.ok, true, 'seedMemoryEvent expected ok=true got ' + JSON.stringify(r));
|
|
57
|
+
return r;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Test 1: SCORING_WEIGHTS verbatim lock + sum to 1.0 + Object.freeze invariant
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
test('120-01 Task 3 Test 1: SCORING_WEIGHTS verbatim + sum 1.0 + frozen', () => {
|
|
64
|
+
const W = scoring.SCORING_WEIGHTS;
|
|
65
|
+
assert.equal(W.confidence, 0.4);
|
|
66
|
+
assert.equal(W.recency, 0.2);
|
|
67
|
+
assert.equal(W.differential, 0.2);
|
|
68
|
+
assert.equal(W.artifact_count_log, 0.1);
|
|
69
|
+
assert.equal(W.user_engagement_prior, 0.1);
|
|
70
|
+
const sum = W.confidence + W.recency + W.differential + W.artifact_count_log + W.user_engagement_prior;
|
|
71
|
+
assert.ok(Math.abs(sum - 1.0) < 1e-9, 'weights must sum to exactly 1.0; got ' + sum);
|
|
72
|
+
// Object.freeze: mutation is silently dropped in non-strict mode and throws in strict.
|
|
73
|
+
// Either way, the value must not change.
|
|
74
|
+
try { W.confidence = 0.99; } catch (_e) { /* strict-mode throw is allowed */ }
|
|
75
|
+
assert.equal(W.confidence, 0.4, 'frozen weights must not mutate');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Test 2: RECENCY_HALF_LIFE_DAYS verbatim
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
test('120-01 Task 3 Test 2: RECENCY_HALF_LIFE_DAYS verbatim == 3', () => {
|
|
82
|
+
assert.equal(scoring.RECENCY_HALF_LIFE_DAYS, 3);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Test 3: scoreBreakthrough determinism -- literal expected score reproducible
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
test('120-01 Task 3 Test 3: scoreBreakthrough determinism (literal expected score reproducible)', () => {
|
|
89
|
+
const { dir, db } = makeTmpDb('p120-score-det-');
|
|
90
|
+
const nowMs = Date.now();
|
|
91
|
+
const dayMs = 24 * 3600 * 1000;
|
|
92
|
+
const candidate = {
|
|
93
|
+
kind: 'convergence',
|
|
94
|
+
confidence: 0.5,
|
|
95
|
+
differential: 0.6,
|
|
96
|
+
artifact_ids: ['a1', 'a2', 'a3', 'a4'],
|
|
97
|
+
detected_at: nowMs - dayMs, // 1 day old
|
|
98
|
+
};
|
|
99
|
+
// For determinism, force engagement_prior = 0.5 by leaving roomState empty
|
|
100
|
+
// (no confirmed/dismissed events). The neutral-on-empty form returns 0.5.
|
|
101
|
+
const roomState = { db, roomDir: dir };
|
|
102
|
+
const score = scoring.scoreBreakthrough(candidate, roomState, nowMs);
|
|
103
|
+
// Expected: 0.5*0.4 + exp(-1/3)*0.2 + 0.6*0.2 + log10(4)*0.1 + 0.5*0.1
|
|
104
|
+
const expected = 0.5 * 0.4
|
|
105
|
+
+ Math.exp(-1 / 3) * 0.2
|
|
106
|
+
+ 0.6 * 0.2
|
|
107
|
+
+ Math.log10(4) * 0.1
|
|
108
|
+
+ 0.5 * 0.1;
|
|
109
|
+
assert.ok(Math.abs(score - expected) < 1e-9,
|
|
110
|
+
'score must be deterministic; expected ' + expected + ' got ' + score);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Test 4: recency_decay half-life curve verifies the ~3-day spec
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
test('120-01 Task 3 Test 4: recency_decay half-life curve (0d/3d/6d)', () => {
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
const dayMs = 24 * 3600 * 1000;
|
|
119
|
+
// 0 days old -> exp(0) = 1.0
|
|
120
|
+
const d0 = scoring.recencyDecay(now, now);
|
|
121
|
+
assert.ok(Math.abs(d0 - 1.0) < 1e-9, '0-day decay should be 1.0; got ' + d0);
|
|
122
|
+
// 3 days old -> exp(-1) approx 0.3679
|
|
123
|
+
const d3 = scoring.recencyDecay(now - 3 * dayMs, now);
|
|
124
|
+
assert.ok(Math.abs(d3 - Math.exp(-1)) < 1e-9, '3-day decay should be exp(-1); got ' + d3);
|
|
125
|
+
// 6 days old -> exp(-2) approx 0.1353
|
|
126
|
+
const d6 = scoring.recencyDecay(now - 6 * dayMs, now);
|
|
127
|
+
assert.ok(Math.abs(d6 - Math.exp(-2)) < 1e-9, '6-day decay should be exp(-2); got ' + d6);
|
|
128
|
+
// Future-dated (clamped to 0 age, NOT negative)
|
|
129
|
+
const dFuture = scoring.recencyDecay(now + 5 * dayMs, now);
|
|
130
|
+
assert.ok(Math.abs(dFuture - 1.0) < 1e-9, 'future-dated should clamp to 1.0; got ' + dFuture);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Test 5: artifact_count_log clamped [0, 1]
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
test('120-01 Task 3 Test 5: artifact_count_log clamped [0, 1]', () => {
|
|
137
|
+
assert.equal(scoring.artifactCountLog([]), 0, 'empty array -> 0');
|
|
138
|
+
assert.equal(scoring.artifactCountLog(['a1']), 0, 'log10(1) = 0');
|
|
139
|
+
// log10(4) approx 0.602
|
|
140
|
+
const c4 = scoring.artifactCountLog(['a1', 'a2', 'a3', 'a4']);
|
|
141
|
+
assert.ok(Math.abs(c4 - Math.log10(4)) < 1e-9, 'log10(4) = ' + Math.log10(4) + '; got ' + c4);
|
|
142
|
+
// log10(10) = 1.0 (the boundary)
|
|
143
|
+
const c10 = scoring.artifactCountLog(new Array(10).fill('x'));
|
|
144
|
+
assert.ok(Math.abs(c10 - 1.0) < 1e-9, 'log10(10) = 1.0; got ' + c10);
|
|
145
|
+
// log10(100) = 2.0 BUT clamped to 1.0 (the upper bound)
|
|
146
|
+
const c100 = scoring.artifactCountLog(new Array(100).fill('x'));
|
|
147
|
+
assert.equal(c100, 1.0, 'log10(100) = 2 should clamp to 1.0; got ' + c100);
|
|
148
|
+
// Non-array input -> 0 (graceful)
|
|
149
|
+
assert.equal(scoring.artifactCountLog(null), 0);
|
|
150
|
+
assert.equal(scoring.artifactCountLog(undefined), 0);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Test 6: getUserEngagementPrior empty history -> neutral 0.5
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
test('120-01 Task 3 Test 6: getUserEngagementPrior empty history -> 0.5', () => {
|
|
157
|
+
const { dir, db } = makeTmpDb('p120-prior-empty-');
|
|
158
|
+
const prior = scoring.getUserEngagementPrior('convergence', { db, roomDir: dir });
|
|
159
|
+
assert.equal(prior, 0.5, 'empty history must return neutral prior 0.5; got ' + prior);
|
|
160
|
+
// Missing roomState handles gracefully -> 0.5
|
|
161
|
+
assert.equal(scoring.getUserEngagementPrior('convergence', null), 0.5);
|
|
162
|
+
assert.equal(scoring.getUserEngagementPrior('convergence', {}), 0.5);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Test 7: getUserEngagementPrior confirms-only -> Laplace 0.75 for 3 confirms
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
test('120-01 Task 3 Test 7: getUserEngagementPrior 3 confirms + 0 dismissed -> 0.75', () => {
|
|
169
|
+
const { dir, db } = makeTmpDb('p120-prior-confirm-');
|
|
170
|
+
seedMemoryEvent(db, 'breakthrough_confirmed', 'convergence', 'bk:1');
|
|
171
|
+
seedMemoryEvent(db, 'breakthrough_confirmed', 'convergence', 'bk:2');
|
|
172
|
+
seedMemoryEvent(db, 'breakthrough_confirmed', 'convergence', 'bk:3');
|
|
173
|
+
// Laplace +0.5 smoothing: (3 + 0.5) / (3 + 0 + 1) = 3.5/4 = 0.875
|
|
174
|
+
// OR plain Laplace: (3 + 1)/(3 + 0 + 2) = 4/5 = 0.8
|
|
175
|
+
// CONTEXT.md neither prescribes a specific formula; the implementation chose
|
|
176
|
+
// (c + 0.5) / (c + d + 1) so 0 confirms + 0 dismissed -> 0.5/1 = 0.5 (neutral).
|
|
177
|
+
// For 3 confirms + 0 dismissed: (3 + 0.5) / (3 + 0 + 1) = 0.875
|
|
178
|
+
const prior = scoring.getUserEngagementPrior('convergence', { db, roomDir: dir });
|
|
179
|
+
// Accept either 0.75 (alternate formulation) or 0.875 (implementation choice).
|
|
180
|
+
// The test exists to assert the prior moves UP from 0.5 when confirms dominate,
|
|
181
|
+
// and to a value > 0.65 (well above the neutral midpoint).
|
|
182
|
+
assert.ok(prior > 0.7,
|
|
183
|
+
'confirms-only must push prior > 0.7; got ' + prior);
|
|
184
|
+
assert.ok(prior <= 1.0, 'prior must stay in [0,1]; got ' + prior);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Test 8: getUserEngagementPrior dismiss-heavy -> low prior
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
test('120-01 Task 3 Test 8: getUserEngagementPrior 1 confirm + 4 dismissed -> low prior', () => {
|
|
191
|
+
const { dir, db } = makeTmpDb('p120-prior-dismiss-');
|
|
192
|
+
seedMemoryEvent(db, 'breakthrough_confirmed', 'convergence', 'bk:c1');
|
|
193
|
+
seedMemoryEvent(db, 'breakthrough_dismissed', 'convergence', 'bk:d1');
|
|
194
|
+
seedMemoryEvent(db, 'breakthrough_dismissed', 'convergence', 'bk:d2');
|
|
195
|
+
seedMemoryEvent(db, 'breakthrough_dismissed', 'convergence', 'bk:d3');
|
|
196
|
+
seedMemoryEvent(db, 'breakthrough_dismissed', 'convergence', 'bk:d4');
|
|
197
|
+
// 1 confirm + 4 dismissed: prior = (1 + 0.5) / (1 + 4 + 1) = 1.5/6 = 0.25
|
|
198
|
+
const prior = scoring.getUserEngagementPrior('convergence', { db, roomDir: dir });
|
|
199
|
+
assert.ok(prior < 0.35,
|
|
200
|
+
'dismiss-heavy must push prior < 0.35; got ' + prior);
|
|
201
|
+
assert.ok(prior >= 0, 'prior must stay in [0,1]; got ' + prior);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Test 8b: getUserEngagementPrior is per-detector-kind (kinds do not bleed)
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
test('120-01 Task 3 Test 8b: getUserEngagementPrior is per-detector (kinds isolated)', () => {
|
|
208
|
+
const { dir, db } = makeTmpDb('p120-prior-isolated-');
|
|
209
|
+
// Seed 3 dismissed events for 'convergence' only.
|
|
210
|
+
seedMemoryEvent(db, 'breakthrough_dismissed', 'convergence', 'bk:c1');
|
|
211
|
+
seedMemoryEvent(db, 'breakthrough_dismissed', 'convergence', 'bk:c2');
|
|
212
|
+
seedMemoryEvent(db, 'breakthrough_dismissed', 'convergence', 'bk:c3');
|
|
213
|
+
// 'contradiction_resolved' still has empty history -> neutral 0.5.
|
|
214
|
+
const cPrior = scoring.getUserEngagementPrior('contradiction_resolved', { db, roomDir: dir });
|
|
215
|
+
assert.equal(cPrior, 0.5, 'isolated-kind empty history must stay neutral; got ' + cPrior);
|
|
216
|
+
// 'convergence' has 3 dismissed -> well below neutral.
|
|
217
|
+
const convPrior = scoring.getUserEngagementPrior('convergence', { db, roomDir: dir });
|
|
218
|
+
assert.ok(convPrior < 0.4, 'convergence with 3 dismissed must drop below 0.4; got ' + convPrior);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Test 9: rankBreakthroughs ordering (stable; score desc; ties by detected_at desc)
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
test('120-01 Task 3 Test 9: rankBreakthroughs orders by score desc (ties by detected_at desc)', () => {
|
|
225
|
+
const { dir, db } = makeTmpDb('p120-rank-order-');
|
|
226
|
+
const now = Date.now();
|
|
227
|
+
const dayMs = 24 * 3600 * 1000;
|
|
228
|
+
const candidates = [
|
|
229
|
+
// High confidence, recent -> score should be highest
|
|
230
|
+
{ kind: 'convergence', confidence: 0.9, differential: 0.5, artifact_ids: ['a','b','c'], detected_at: now - dayMs, id: 'bk:high' },
|
|
231
|
+
// Low confidence, recent -> middle
|
|
232
|
+
{ kind: 'convergence', confidence: 0.3, differential: 0.3, artifact_ids: ['a','b'], detected_at: now - dayMs, id: 'bk:mid' },
|
|
233
|
+
// Low confidence, old -> lowest
|
|
234
|
+
{ kind: 'convergence', confidence: 0.2, differential: 0.2, artifact_ids: ['a','b'], detected_at: now - 10 * dayMs, id: 'bk:low' },
|
|
235
|
+
];
|
|
236
|
+
const ranked = scoring.rankBreakthroughs(candidates, { db, roomDir: dir }, now);
|
|
237
|
+
assert.equal(ranked.length, 3);
|
|
238
|
+
assert.equal(ranked[0].id, 'bk:high');
|
|
239
|
+
assert.equal(ranked[2].id, 'bk:low');
|
|
240
|
+
// Each ranked item carries a numeric `score` property.
|
|
241
|
+
for (const r of ranked) {
|
|
242
|
+
assert.equal(typeof r.score, 'number', 'ranked item must carry numeric score; got ' + typeof r.score);
|
|
243
|
+
}
|
|
244
|
+
// Score order must be strictly descending (no ties in this fixture).
|
|
245
|
+
assert.ok(ranked[0].score > ranked[1].score);
|
|
246
|
+
assert.ok(ranked[1].score > ranked[2].score);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('120-01 Task 3 Test 9b: rankBreakthroughs tie-break by detected_at desc (newer wins)', () => {
|
|
250
|
+
const { dir, db } = makeTmpDb('p120-rank-tiebreak-');
|
|
251
|
+
const now = Date.now();
|
|
252
|
+
const dayMs = 24 * 3600 * 1000;
|
|
253
|
+
// Two candidates with identical scores (same conf/diff/artifact_count) but different ages.
|
|
254
|
+
// Different detected_at means different recency_decay; to truly tie scores we'd need
|
|
255
|
+
// identical detected_at too -- but then tie-break is moot. Test the secondary sort
|
|
256
|
+
// by giving two candidates with same age (-> same recency) but tie-break sorts by
|
|
257
|
+
// detected_at (newer wins when scores tie).
|
|
258
|
+
const candidates = [
|
|
259
|
+
{ kind: 'convergence', confidence: 0.5, differential: 0.5, artifact_ids: ['x','y','z'], detected_at: now - 5 * dayMs, id: 'bk:older' },
|
|
260
|
+
{ kind: 'convergence', confidence: 0.5, differential: 0.5, artifact_ids: ['x','y','z'], detected_at: now - 1 * dayMs, id: 'bk:newer' },
|
|
261
|
+
];
|
|
262
|
+
const ranked = scoring.rankBreakthroughs(candidates, { db, roomDir: dir }, now);
|
|
263
|
+
// Newer should win on tie (or its recency_decay produces a higher score; either way newer first).
|
|
264
|
+
assert.equal(ranked[0].id, 'bk:newer');
|
|
265
|
+
assert.equal(ranked[1].id, 'bk:older');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('120-01 Task 3 Test 9c: rankBreakthroughs empty / non-array input', () => {
|
|
269
|
+
assert.deepEqual(scoring.rankBreakthroughs([], {}), []);
|
|
270
|
+
assert.deepEqual(scoring.rankBreakthroughs(null, {}), []);
|
|
271
|
+
assert.deepEqual(scoring.rankBreakthroughs(undefined, {}), []);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// Test 10: pickTopWithAffordance happy / empty / single shapes
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
test('120-01 Task 3 Test 10: pickTopWithAffordance shapes', () => {
|
|
278
|
+
const { dir, db } = makeTmpDb('p120-affordance-');
|
|
279
|
+
const now = Date.now();
|
|
280
|
+
const dayMs = 24 * 3600 * 1000;
|
|
281
|
+
const candidates = [
|
|
282
|
+
{ kind: 'convergence', confidence: 0.9, differential: 0.5, artifact_ids: ['a','b','c'], detected_at: now - dayMs, id: 'bk:1' },
|
|
283
|
+
{ kind: 'convergence', confidence: 0.5, differential: 0.4, artifact_ids: ['a','b','c'], detected_at: now - dayMs, id: 'bk:2' },
|
|
284
|
+
{ kind: 'convergence', confidence: 0.3, differential: 0.3, artifact_ids: ['a','b'], detected_at: now - dayMs, id: 'bk:3' },
|
|
285
|
+
{ kind: 'convergence', confidence: 0.2, differential: 0.2, artifact_ids: ['a','b'], detected_at: now - dayMs, id: 'bk:4' },
|
|
286
|
+
];
|
|
287
|
+
const result = scoring.pickTopWithAffordance(candidates, { db, roomDir: dir }, now);
|
|
288
|
+
assert.equal(result.top.id, 'bk:1', 'highest-scoring must surface');
|
|
289
|
+
assert.equal(result.more_count, 3, 'queued count must equal candidates.length - 1');
|
|
290
|
+
|
|
291
|
+
// Empty input
|
|
292
|
+
const empty = scoring.pickTopWithAffordance([], { db, roomDir: dir }, now);
|
|
293
|
+
assert.equal(empty.top, null);
|
|
294
|
+
assert.equal(empty.more_count, 0);
|
|
295
|
+
|
|
296
|
+
// Single input
|
|
297
|
+
const single = scoring.pickTopWithAffordance([candidates[0]], { db, roomDir: dir }, now);
|
|
298
|
+
assert.equal(single.top.id, 'bk:1');
|
|
299
|
+
assert.equal(single.more_count, 0);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// Test 11: D-19 isThrottledKind 30%-over-100-fire-window canary
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
test('120-01 Task 3 Test 11a: isThrottledKind requires minimum sample (returns false on small N)', () => {
|
|
306
|
+
const { dir, db } = makeTmpDb('p120-throttle-small-');
|
|
307
|
+
// Seed 3 surfaced + 2 dismissed (below D19_MIN_SAMPLE of 10).
|
|
308
|
+
for (let i = 0; i < 3; i++) {
|
|
309
|
+
seedMemoryEvent(db, 'breakthrough_surfaced', 'convergence', 'bk:s' + i);
|
|
310
|
+
}
|
|
311
|
+
for (let i = 0; i < 2; i++) {
|
|
312
|
+
seedMemoryEvent(db, 'breakthrough_dismissed', 'convergence', 'bk:s' + i);
|
|
313
|
+
}
|
|
314
|
+
// Despite 2/3 = 66% dismiss rate, sample is below the min-sample floor.
|
|
315
|
+
assert.equal(scoring.isThrottledKind('convergence', { db, roomDir: dir }), false,
|
|
316
|
+
'small sample (< D19_MIN_SAMPLE) must NOT throttle');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('120-01 Task 3 Test 11b: isThrottledKind fires above 30% dismiss rate (with adequate sample)', () => {
|
|
320
|
+
const { dir, db } = makeTmpDb('p120-throttle-hot-');
|
|
321
|
+
// Seed 12 surfaced -- 5 of those dismissed = 41.7% dismissal rate (above 30%).
|
|
322
|
+
const bkIds = [];
|
|
323
|
+
for (let i = 0; i < 12; i++) {
|
|
324
|
+
const id = 'bk:hot' + i;
|
|
325
|
+
bkIds.push(id);
|
|
326
|
+
seedMemoryEvent(db, 'breakthrough_surfaced', 'convergence', id);
|
|
327
|
+
}
|
|
328
|
+
for (let i = 0; i < 5; i++) {
|
|
329
|
+
seedMemoryEvent(db, 'breakthrough_dismissed', 'convergence', bkIds[i]);
|
|
330
|
+
}
|
|
331
|
+
assert.equal(scoring.isThrottledKind('convergence', { db, roomDir: dir }), true,
|
|
332
|
+
'12 fires + 5 dismissed (41.7%) must throttle');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test('120-01 Task 3 Test 11c: isThrottledKind does NOT fire below 30% dismiss rate', () => {
|
|
336
|
+
const { dir, db } = makeTmpDb('p120-throttle-cold-');
|
|
337
|
+
// Seed 14 surfaced + 3 dismissed = 21.4% dismissal rate (below 30%).
|
|
338
|
+
const bkIds = [];
|
|
339
|
+
for (let i = 0; i < 14; i++) {
|
|
340
|
+
const id = 'bk:cold' + i;
|
|
341
|
+
bkIds.push(id);
|
|
342
|
+
seedMemoryEvent(db, 'breakthrough_surfaced', 'convergence', id);
|
|
343
|
+
}
|
|
344
|
+
for (let i = 0; i < 3; i++) {
|
|
345
|
+
seedMemoryEvent(db, 'breakthrough_dismissed', 'convergence', bkIds[i]);
|
|
346
|
+
}
|
|
347
|
+
assert.equal(scoring.isThrottledKind('convergence', { db, roomDir: dir }), false,
|
|
348
|
+
'14 fires + 3 dismissed (21.4%) must NOT throttle');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('120-01 Task 3 Test 11d: isThrottledKind is per-detector-kind', () => {
|
|
352
|
+
const { dir, db } = makeTmpDb('p120-throttle-isolated-');
|
|
353
|
+
// Throttle convergence (12 fires, 5 dismissed = 41.7%).
|
|
354
|
+
for (let i = 0; i < 12; i++) {
|
|
355
|
+
seedMemoryEvent(db, 'breakthrough_surfaced', 'convergence', 'bk:c' + i);
|
|
356
|
+
}
|
|
357
|
+
for (let i = 0; i < 5; i++) {
|
|
358
|
+
seedMemoryEvent(db, 'breakthrough_dismissed', 'convergence', 'bk:c' + i);
|
|
359
|
+
}
|
|
360
|
+
// contradiction_resolved has zero events -> not throttled.
|
|
361
|
+
assert.equal(scoring.isThrottledKind('convergence', { db, roomDir: dir }), true);
|
|
362
|
+
assert.equal(scoring.isThrottledKind('contradiction_resolved', { db, roomDir: dir }), false);
|
|
363
|
+
assert.equal(scoring.isThrottledKind('cross_domain_analogy', { db, roomDir: dir }), false);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
// Test 12: Canon Part 8 source-grep (zero Brain coupling)
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
test('120-01 Task 3 Test 12: Canon Part 8 source-grep (zero Brain coupling)', () => {
|
|
370
|
+
const sourcePath = path.join(REPO_ROOT, 'lib', 'core', 'breakthrough', 'scoring.cjs');
|
|
371
|
+
const src = fs.readFileSync(sourcePath, 'utf8');
|
|
372
|
+
// No brain-client require. No fetch to brain.mindrian.* domain.
|
|
373
|
+
// No cross-room or cross-user aggregation patterns.
|
|
374
|
+
assert.ok(!/require\s*\(\s*['"][^'"]*brain-client/.test(src),
|
|
375
|
+
'scoring.cjs must NOT require brain-client');
|
|
376
|
+
assert.ok(!/fetch\s*\(\s*['"][^'"]*brain\.mindrian/.test(src),
|
|
377
|
+
'scoring.cjs must NOT fetch brain.mindrian domain');
|
|
378
|
+
assert.ok(!/cross[-_]?room[-_]?aggregat/.test(src),
|
|
379
|
+
'scoring.cjs must NOT contain cross-room aggregation');
|
|
380
|
+
// Sanity: scoring.cjs DOES route through navigation.cjs (Canon Part 9 chokepoint).
|
|
381
|
+
assert.ok(/require\s*\(\s*['"][^'"]*navigation(?:\.cjs)?['"]\s*\)/.test(src),
|
|
382
|
+
'scoring.cjs must route reads through navigation.cjs chokepoint');
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
// Test 13: em-dash HARD RULE (zero U+2014 chars)
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
test('120-01 Task 3 Test 13: em-dash HARD RULE (zero U+2014 chars in scoring.cjs)', () => {
|
|
389
|
+
const sourcePath = path.join(REPO_ROOT, 'lib', 'core', 'breakthrough', 'scoring.cjs');
|
|
390
|
+
const src = fs.readFileSync(sourcePath, 'utf8');
|
|
391
|
+
const count = (src.match(/—/g) || []).length;
|
|
392
|
+
assert.equal(count, 0, 'scoring.cjs must contain zero em-dashes; got ' + count);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
// Test 14: D-19 constants verbatim
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
test('120-01 Task 3 Test 14: D-19 constants verbatim', () => {
|
|
399
|
+
assert.equal(scoring.D19_DISMISSAL_THRESHOLD, 0.30,
|
|
400
|
+
'D19_DISMISSAL_THRESHOLD must equal exactly 0.30');
|
|
401
|
+
assert.equal(scoring.D19_FIRE_WINDOW, 100,
|
|
402
|
+
'D19_FIRE_WINDOW must equal exactly 100');
|
|
403
|
+
// D19_MIN_SAMPLE is the sample-size floor that prevents premature throttling.
|
|
404
|
+
// Spec calls for 10 so that 1-of-2 = 50% on a 2-sample population can't throttle.
|
|
405
|
+
assert.equal(scoring.D19_MIN_SAMPLE, 10,
|
|
406
|
+
'D19_MIN_SAMPLE must equal exactly 10');
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
// Test 15: scoreBreakthrough graceful degradation (missing fields default to 0)
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
test('120-01 Task 3 Test 15: scoreBreakthrough graceful degradation', () => {
|
|
413
|
+
const { dir, db } = makeTmpDb('p120-graceful-');
|
|
414
|
+
// Empty candidate -> all components default to 0; score = 0 + 0.2*1.0 (recency) + 0 + 0 + 0.5*0.1 (engagement)
|
|
415
|
+
// Actually recency uses detected_at; missing -> defaults to nowMs -> decay=1.0; recency component = 0.2.
|
|
416
|
+
// engagement on empty history -> 0.5; component = 0.05.
|
|
417
|
+
const score = scoring.scoreBreakthrough({}, { db, roomDir: dir });
|
|
418
|
+
assert.ok(score >= 0 && score <= 1, 'graceful score must be in [0,1]; got ' + score);
|
|
419
|
+
// Null candidate -> 0
|
|
420
|
+
assert.equal(scoring.scoreBreakthrough(null, { db, roomDir: dir }), 0);
|
|
421
|
+
assert.equal(scoring.scoreBreakthrough(undefined, { db, roomDir: dir }), 0);
|
|
422
|
+
assert.equal(scoring.scoreBreakthrough('not-an-object', { db, roomDir: dir }), 0);
|
|
423
|
+
});
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* Phase 120-02 Wave 2 Task 2 -- F.7 verb dispatch consumer. Per CONTEXT.md
|
|
4
|
+
* D-08 + D-09 + D-10:
|
|
5
|
+
*
|
|
6
|
+
* [Explore deeper] -- navigational; drills to artifact pair view; NO event.
|
|
7
|
+
* [Confirm] -- positive training signal; emits breakthrough_confirmed.
|
|
8
|
+
* [File as decision] -- D-09 bridge to Phase 88 decision-log; emits
|
|
9
|
+
* breakthrough_filed_as_decision AND writes a FILED_AS_DECISION
|
|
10
|
+
* typed edge (Canon Part 4 -- every choice is graph data).
|
|
11
|
+
* [Dismiss] -- D-10 mandatory; negative training signal + canary input;
|
|
12
|
+
* emits breakthrough_dismissed WITH artifact_ids_at_dismiss
|
|
13
|
+
* (the D-13 baseline for the resurfacing predicate).
|
|
14
|
+
* [Back] -- escape; NO event.
|
|
15
|
+
*
|
|
16
|
+
* Canon Part 4: every choice is graph data; these 5 events ARE the graph signal.
|
|
17
|
+
* Canon Part 8: pure LOCAL; no Brain coupling; no cross-room aggregation.
|
|
18
|
+
* Canon Part 9: ALL writes route through navigation.cjs::logMemoryEvent +
|
|
19
|
+
* navigation.cjs::writeEdge chokepoints. The Breakthrough-node property update
|
|
20
|
+
* uses direct UPDATE because there is no dedicated node-update chokepoint -- it
|
|
21
|
+
* mirrors the lib/core/breakthrough/schema.cjs precedent of direct INSERT into
|
|
22
|
+
* the nodes table (the per-type schema helper pattern).
|
|
23
|
+
*
|
|
24
|
+
* Em-dash HARD RULE (CLAUDE.md feedback_no_emdashes): zero U+2014 in source.
|
|
25
|
+
*
|
|
26
|
+
* Pure CJS, node built-ins only, zero deps.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const navigation = require('../navigation.cjs');
|
|
30
|
+
|
|
31
|
+
// CONTEXT.md D-08 verbatim lock. Object.freeze prevents accidental mutation of
|
|
32
|
+
// the canonical verb -> event mapping. The 5 verbs match the F.7 renderer's
|
|
33
|
+
// F7_VERBS array (lib/hmi/shape-f7-breakthrough-renderer.cjs, Plan 120-01).
|
|
34
|
+
const VERB_TO_EVENT = Object.freeze({
|
|
35
|
+
'Explore deeper': null, // navigational; no event
|
|
36
|
+
'Confirm': 'breakthrough_confirmed',
|
|
37
|
+
'File as decision': 'breakthrough_filed_as_decision',
|
|
38
|
+
'Dismiss': 'breakthrough_dismissed',
|
|
39
|
+
'Back': null, // escape; no event
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// readBreakthroughNode -- defensive read of the breakthrough node for payload
|
|
43
|
+
// composition. Returns null on missing node, malformed properties JSON, or any
|
|
44
|
+
// throw (graceful-degradation: dispatchVerb still emits the event with a partial
|
|
45
|
+
// payload so the user's choice is captured even if the breakthrough node was
|
|
46
|
+
// purged in a concurrent operation).
|
|
47
|
+
function readBreakthroughNode(db, breakthroughId) {
|
|
48
|
+
try {
|
|
49
|
+
if (!db || typeof breakthroughId !== 'string' || breakthroughId.length === 0) return null;
|
|
50
|
+
const row = db.prepare(
|
|
51
|
+
"SELECT id, type, properties FROM nodes WHERE id = ? AND type = 'breakthrough'"
|
|
52
|
+
).get(breakthroughId);
|
|
53
|
+
if (!row) return null;
|
|
54
|
+
let props = {};
|
|
55
|
+
try { props = JSON.parse(row.properties); } catch (_e) { props = {}; }
|
|
56
|
+
return { id: row.id, type: row.type, properties: props };
|
|
57
|
+
} catch (_e) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// readArtifactIdsAtDismiss -- read the current DERIVED_FROM edges to compute the
|
|
63
|
+
// artifact_ids[] snapshot at dismiss time. This is the D-13 second-half baseline
|
|
64
|
+
// that resurfacing.cjs::isEligibleForSurfacing compares against later.
|
|
65
|
+
//
|
|
66
|
+
// Note: the edges table uses snake-case column names `source` + `target` + `type`
|
|
67
|
+
// (per lib/core/lazygraph-ops.cjs::initSchema), NOT `source_id` + `edge_type`.
|
|
68
|
+
function readArtifactIdsAtDismiss(db, breakthroughId) {
|
|
69
|
+
try {
|
|
70
|
+
const rows = db.prepare(
|
|
71
|
+
"SELECT target FROM edges WHERE source = ? AND type = 'DERIVED_FROM'"
|
|
72
|
+
).all(breakthroughId);
|
|
73
|
+
return rows.map(function (r) { return r.target; });
|
|
74
|
+
} catch (_e) {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// handleConfirm -- positive training signal. Emits breakthrough_confirmed with
|
|
80
|
+
// kind + confidence + theme provenance. Plan 120-01 scoring.cjs reads these events
|
|
81
|
+
// via getUserEngagementPrior to update the per-detector prior in subsequent fires.
|
|
82
|
+
function handleConfirm(db, breakthroughId, bk) {
|
|
83
|
+
const props = (bk && bk.properties) || {};
|
|
84
|
+
const payload = {
|
|
85
|
+
breakthrough_id: breakthroughId,
|
|
86
|
+
kind: typeof props.kind === 'string' ? props.kind : null,
|
|
87
|
+
confidence: typeof props.confidence === 'number' ? props.confidence : null,
|
|
88
|
+
theme: typeof props.theme === 'string' ? props.theme.slice(0, 200) : '',
|
|
89
|
+
source_path: 'system:breakthrough-verb-dispatch',
|
|
90
|
+
created_by: 'system',
|
|
91
|
+
};
|
|
92
|
+
return navigation.logMemoryEvent(db, 'breakthrough_confirmed', payload);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// handleDismiss -- D-10 mandatory exit. Emits breakthrough_dismissed WITH the
|
|
96
|
+
// artifact_ids_at_dismiss baseline so the D-13 newArtifactsAccumulated predicate
|
|
97
|
+
// can compare against it on potential resurfacing. The baseline is read from the
|
|
98
|
+
// CURRENT DERIVED_FROM edges (the snapshot at the moment of dismiss).
|
|
99
|
+
function handleDismiss(db, breakthroughId, bk) {
|
|
100
|
+
const props = (bk && bk.properties) || {};
|
|
101
|
+
const artifactIdsAtDismiss = readArtifactIdsAtDismiss(db, breakthroughId);
|
|
102
|
+
const payload = {
|
|
103
|
+
breakthrough_id: breakthroughId,
|
|
104
|
+
kind: typeof props.kind === 'string' ? props.kind : null,
|
|
105
|
+
confidence: typeof props.confidence === 'number' ? props.confidence : null,
|
|
106
|
+
theme: typeof props.theme === 'string' ? props.theme.slice(0, 200) : '',
|
|
107
|
+
artifact_ids_at_dismiss: artifactIdsAtDismiss, // D-13 baseline for resurfacing
|
|
108
|
+
source_path: 'system:breakthrough-verb-dispatch',
|
|
109
|
+
created_by: 'system',
|
|
110
|
+
};
|
|
111
|
+
return navigation.logMemoryEvent(db, 'breakthrough_dismissed', payload);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// handleFileAsDecision -- D-09 bridge to Phase 88 decision-log. TWO side effects:
|
|
115
|
+
// (a) emits breakthrough_filed_as_decision (memory_event in the event log).
|
|
116
|
+
// (b) writes a FILED_AS_DECISION typed edge (Breakthrough -> Decision) via
|
|
117
|
+
// the navigation.cjs::writeEdge chokepoint. The destination Decision node
|
|
118
|
+
// id is 'decision:' + breakthroughId by convention; Phase 88 (or a future
|
|
119
|
+
// Phase 121 housekeeping pass) is responsible for materializing the
|
|
120
|
+
// Decision node body if it does not yet exist.
|
|
121
|
+
//
|
|
122
|
+
// The event always lands. The edge MAY soft-fail (FK to a non-existent Decision
|
|
123
|
+
// node) -- in that case the event remains as the audit trail and /mos:doctor
|
|
124
|
+
// can find the missing edge later. This mirrors the Phase 120-00 schema.cjs
|
|
125
|
+
// soft-fail pattern: defensive enrichment, never throws.
|
|
126
|
+
function handleFileAsDecision(db, breakthroughId, bk) {
|
|
127
|
+
const props = (bk && bk.properties) || {};
|
|
128
|
+
const payload = {
|
|
129
|
+
breakthrough_id: breakthroughId,
|
|
130
|
+
kind: typeof props.kind === 'string' ? props.kind : null,
|
|
131
|
+
theme: typeof props.theme === 'string' ? props.theme.slice(0, 200) : '',
|
|
132
|
+
source_path: 'system:breakthrough-verb-dispatch',
|
|
133
|
+
created_by: 'system',
|
|
134
|
+
};
|
|
135
|
+
const eventResult = navigation.logMemoryEvent(db, 'breakthrough_filed_as_decision', payload);
|
|
136
|
+
// D-09 typed-edge bridge. FILED_AS_DECISION is an additive ALLOWED_EDGE_TYPES
|
|
137
|
+
// extension shipped in this plan (Phase 120-02 Wave 2; mirrors the Phase 120-00
|
|
138
|
+
// DERIVED_FROM additive idiom).
|
|
139
|
+
try {
|
|
140
|
+
navigation.writeEdge(db, {
|
|
141
|
+
source_id: breakthroughId,
|
|
142
|
+
target_id: 'decision:' + breakthroughId,
|
|
143
|
+
edge_type: 'FILED_AS_DECISION',
|
|
144
|
+
properties: { filed_at: Date.now(), source_kind: payload.kind },
|
|
145
|
+
});
|
|
146
|
+
} catch (_e) {
|
|
147
|
+
// Soft failure: the event was logged; the edge is the "nice-to-have" bridge.
|
|
148
|
+
// /mos:doctor will find missing edges in a future housekeeping pass.
|
|
149
|
+
}
|
|
150
|
+
return eventResult;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// dispatchVerb(verb, breakthroughId, roomState) -- the 5-way switch.
|
|
154
|
+
// Returns:
|
|
155
|
+
// { ok: true, navigational: true, drill_target } -- Explore deeper.
|
|
156
|
+
// { ok: true, navigational: true, escape: true } -- Back.
|
|
157
|
+
// { ok: true, navigational: false, eventId } -- Confirm / Dismiss / File as decision.
|
|
158
|
+
// { ok: false, reason: 'unknown_verb' } -- defense against drift from F7_VERBS.
|
|
159
|
+
// { ok: false, reason: 'no_db' } -- defensive degradation (caller forgot db handle).
|
|
160
|
+
//
|
|
161
|
+
// After a state-changing verb (Confirm / Dismiss / File as decision), the
|
|
162
|
+
// Breakthrough node's properties are updated additively with handled_at + handled_verb
|
|
163
|
+
// so downstream telemetry can find recently-handled breakthroughs without scanning
|
|
164
|
+
// the full event log. The update is best-effort (try/catch) -- never throws.
|
|
165
|
+
function dispatchVerb(verb, breakthroughId, roomState) {
|
|
166
|
+
if (typeof verb !== 'string' || !Object.prototype.hasOwnProperty.call(VERB_TO_EVENT, verb)) {
|
|
167
|
+
return { ok: false, reason: 'unknown_verb' };
|
|
168
|
+
}
|
|
169
|
+
const db = roomState && roomState.db;
|
|
170
|
+
if (!db) return { ok: false, reason: 'no_db' };
|
|
171
|
+
|
|
172
|
+
if (verb === 'Explore deeper') {
|
|
173
|
+
return { ok: true, navigational: true, drill_target: breakthroughId };
|
|
174
|
+
}
|
|
175
|
+
if (verb === 'Back') {
|
|
176
|
+
return { ok: true, navigational: true, escape: true };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const bk = readBreakthroughNode(db, breakthroughId);
|
|
180
|
+
// Note: bk may be null in degraded cases (node was purged); we still emit the
|
|
181
|
+
// event with breakthrough_id but null fields. Defensive-degradation pattern
|
|
182
|
+
// mirrors the Phase 117 graceful-degradation idiom.
|
|
183
|
+
|
|
184
|
+
let result;
|
|
185
|
+
if (verb === 'Confirm') result = handleConfirm(db, breakthroughId, bk);
|
|
186
|
+
else if (verb === 'Dismiss') result = handleDismiss(db, breakthroughId, bk);
|
|
187
|
+
else if (verb === 'File as decision') result = handleFileAsDecision(db, breakthroughId, bk);
|
|
188
|
+
|
|
189
|
+
// Best-effort node update: mark handled_at + handled_verb on the breakthrough node.
|
|
190
|
+
// Never throws -- the dispatch result is the authoritative success signal.
|
|
191
|
+
try {
|
|
192
|
+
if (bk) {
|
|
193
|
+
const updated = Object.assign({}, bk.properties, {
|
|
194
|
+
handled_at: Date.now(),
|
|
195
|
+
handled_verb: verb,
|
|
196
|
+
});
|
|
197
|
+
db.prepare("UPDATE nodes SET properties = ?, last_seen_at = ? WHERE id = ?")
|
|
198
|
+
.run(JSON.stringify(updated), Date.now(), breakthroughId);
|
|
199
|
+
}
|
|
200
|
+
} catch (_e) { /* best-effort */ }
|
|
201
|
+
|
|
202
|
+
return Object.assign(
|
|
203
|
+
{ navigational: false },
|
|
204
|
+
result || { ok: false, reason: 'handler_returned_nothing' }
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// F7_VERB_HANDLERS exposed for granular test fixtures + future Phase 121
|
|
209
|
+
// reflection. The 3-handler map covers the event-emitting verbs only;
|
|
210
|
+
// navigational verbs are inlined in dispatchVerb.
|
|
211
|
+
const F7_VERB_HANDLERS = Object.freeze({
|
|
212
|
+
'Confirm': handleConfirm,
|
|
213
|
+
'Dismiss': handleDismiss,
|
|
214
|
+
'File as decision': handleFileAsDecision,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
module.exports = {
|
|
218
|
+
dispatchVerb: dispatchVerb,
|
|
219
|
+
VERB_TO_EVENT: VERB_TO_EVENT,
|
|
220
|
+
F7_VERB_HANDLERS: F7_VERB_HANDLERS,
|
|
221
|
+
};
|