@mindrian_os/install 1.13.0-beta.16 → 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 +36 -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 +4 -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 +4 -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 +58 -0
- package/commands/mva-option.md +91 -0
- package/commands/new-project.md +4 -0
- package/commands/onboard.md +22 -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 +31 -88
- package/lib/agents/auto-explore-agent.cjs +82 -0
- package/lib/agents/mva/brain-classic-traps.cjs +77 -0
- package/lib/agents/mva/brain-cross-domain.cjs +79 -0
- package/lib/agents/mva/brain-similar-ventures.cjs +93 -0
- package/lib/agents/mva/dashboard-graph-neighborhood.cjs +72 -0
- package/lib/agents/mva/index.cjs +42 -0
- package/lib/agents/mva/six-hats-red-black.cjs +137 -0
- package/lib/agents/mva/tavily-funding-scan.cjs +147 -0
- package/lib/agents/mva/test-all-six-agents.cjs +467 -0
- package/lib/conversation/operator.cjs +64 -0
- package/lib/conversation/operator.test.cjs +160 -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-agent-contract.cjs +170 -0
- package/lib/core/mva-agent-contract.test.cjs +169 -0
- package/lib/core/mva-budget.cjs +75 -0
- package/lib/core/mva-budget.test.cjs +68 -0
- package/lib/core/mva-classifier.cjs +370 -0
- package/lib/core/mva-classifier.test.cjs +248 -0
- package/lib/core/mva-deck-builder.cjs +452 -0
- package/lib/core/mva-deck-builder.test.cjs +287 -0
- package/lib/core/mva-detect.smoke.test.cjs +197 -0
- package/lib/core/mva-dispatcher.cjs +110 -0
- package/lib/core/mva-dispatcher.test.cjs +216 -0
- package/lib/core/mva-option-router.cjs +292 -0
- package/lib/core/mva-option-router.test.cjs +483 -0
- package/lib/core/mva-orchestrator.cjs +365 -0
- package/lib/core/mva-orchestrator.test.cjs +908 -0
- package/lib/core/mva-progressive-renderer.cjs +194 -0
- package/lib/core/mva-progressive-renderer.test.cjs +157 -0
- package/lib/core/mva-rule-linter.cjs +213 -0
- package/lib/core/mva-rule-linter.test.cjs +336 -0
- package/lib/core/mva-state.cjs +159 -0
- package/lib/core/mva-telemetry.cjs +58 -0
- package/lib/core/mva-telemetry.test.cjs +196 -0
- package/lib/core/mva-vercel-deploy.cjs +168 -0
- package/lib/core/mva-vercel-deploy.test.cjs +239 -0
- package/lib/core/navigation/dashboard-helpers.cjs +145 -0
- package/lib/core/navigation/edges.cjs +35 -0
- package/lib/core/navigation/memory-events.cjs +126 -0
- package/lib/core/navigation.cjs +11 -0
- package/lib/core/resolve-vercel-key.cjs +107 -0
- package/lib/core/resolve-vercel-key.test.cjs +137 -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 +240 -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/mva-pipeline/SKILL.md +129 -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,256 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Phase 120-00 Wave 1 Task 3 -- the Breakthrough graph schema + the D-20 HARD FLOOR
|
|
5
|
+
* structural enforcement test surface.
|
|
6
|
+
*
|
|
7
|
+
* 12 tests covering:
|
|
8
|
+
* 1. validateProvenance happy path
|
|
9
|
+
* 2. validateProvenance rejects empty artifact_ids (D-20 HARD FLOOR)
|
|
10
|
+
* 3. validateProvenance rejects empty-string artifact ids
|
|
11
|
+
* 4. validateProvenance rejects missing breakthrough.id
|
|
12
|
+
* 5. writeBreakthrough happy path -- node + edges land via atomic transaction
|
|
13
|
+
* 6. writeBreakthrough rejects provenance-less input
|
|
14
|
+
* 7. D-20 CONSTITUTIONAL TEST: post-write SQL invariant
|
|
15
|
+
* SELECT COUNT(*) FROM edges WHERE source=$bk AND type='DERIVED_FROM' >= 1
|
|
16
|
+
* 8. Atomic rollback on writeEdge failure (FK violation -- target missing)
|
|
17
|
+
* 9. Initial surfaced flag is false
|
|
18
|
+
* 10. D-20 BATCH INVARIANT: zero orphaned breakthroughs after all writes
|
|
19
|
+
* 11. Canon Part 8 source-grep (zero brain-client require)
|
|
20
|
+
* 12. writeBreakthrough does NOT emit breakthrough_surfaced (scanner does that)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const test = require('node:test');
|
|
24
|
+
const { strict: assert } = require('node:assert');
|
|
25
|
+
const fs = require('node:fs');
|
|
26
|
+
const os = require('node:os');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
|
|
29
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
30
|
+
const schema = require(path.join(REPO_ROOT, 'lib', 'core', 'breakthrough', 'schema.cjs'));
|
|
31
|
+
const { openRoomDb, closeRoomDb } = require(path.join(REPO_ROOT, 'lib', 'core', 'room-db.cjs'));
|
|
32
|
+
|
|
33
|
+
function makeTmpDb(prefix) {
|
|
34
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
35
|
+
fs.mkdirSync(path.join(dir, '.mindrian'), { recursive: true });
|
|
36
|
+
const db = openRoomDb(dir);
|
|
37
|
+
return { dir, db };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function seedArtifact(db, id) {
|
|
41
|
+
const nowMs = Date.now();
|
|
42
|
+
db.prepare(
|
|
43
|
+
"INSERT INTO nodes (id, type, properties, source_path, created_by, review_status, created_at, last_seen_at) " +
|
|
44
|
+
"VALUES (?, 'artifact', '{}', 'test', 'system', 'confirmed', ?, ?)"
|
|
45
|
+
).run(id, nowMs, nowMs);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test('120-00 Task 3 Test 1: validateProvenance happy path', () => {
|
|
49
|
+
const r = schema.validateProvenance({ id: 'bk:1', artifact_ids: ['a1', 'a2'] });
|
|
50
|
+
assert.equal(r.ok, true);
|
|
51
|
+
assert.deepEqual(r.sanitized_artifact_ids, ['a1', 'a2']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('120-00 Task 3 Test 2: validateProvenance D-20 HARD FLOOR rejects empty artifact_ids', () => {
|
|
55
|
+
const r = schema.validateProvenance({ id: 'bk:1', artifact_ids: [] });
|
|
56
|
+
assert.equal(r.ok, false);
|
|
57
|
+
assert.equal(r.reason, 'provenance_required');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('120-00 Task 3 Test 3: validateProvenance rejects empty-string artifact ids', () => {
|
|
61
|
+
const r = schema.validateProvenance({ id: 'bk:1', artifact_ids: ['', null, undefined] });
|
|
62
|
+
assert.equal(r.ok, false);
|
|
63
|
+
assert.equal(r.reason, 'provenance_required');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('120-00 Task 3 Test 4: validateProvenance rejects missing breakthrough.id', () => {
|
|
67
|
+
const r = schema.validateProvenance({ artifact_ids: ['a1'] });
|
|
68
|
+
assert.equal(r.ok, false);
|
|
69
|
+
assert.equal(r.reason, 'breakthrough_id_required');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('120-00 Task 3 Test 4b: validateProvenance rejects non-object input', () => {
|
|
73
|
+
assert.equal(schema.validateProvenance(null).ok, false);
|
|
74
|
+
assert.equal(schema.validateProvenance(undefined).ok, false);
|
|
75
|
+
assert.equal(schema.validateProvenance('string').ok, false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('120-00 Task 3 Test 5: writeBreakthrough happy path -- node + edges land atomically', () => {
|
|
79
|
+
const { dir, db } = makeTmpDb('p120-bk-happy-');
|
|
80
|
+
seedArtifact(db, 'a1');
|
|
81
|
+
seedArtifact(db, 'a2');
|
|
82
|
+
const nowMs = Date.now();
|
|
83
|
+
const candidate = {
|
|
84
|
+
id: 'bk:happy',
|
|
85
|
+
kind: 'convergence',
|
|
86
|
+
confidence: 0.55,
|
|
87
|
+
artifact_ids: ['a1', 'a2'],
|
|
88
|
+
theme: 'X',
|
|
89
|
+
differential: 0.6,
|
|
90
|
+
cross_section_linked: false,
|
|
91
|
+
detected_at: nowMs,
|
|
92
|
+
window_start: nowMs - 14 * 86400000,
|
|
93
|
+
window_end: nowMs,
|
|
94
|
+
};
|
|
95
|
+
const r = schema.writeBreakthrough(db, candidate);
|
|
96
|
+
assert.equal(r.ok, true, 'writeBreakthrough should succeed; got reason=' + (r.reason || ''));
|
|
97
|
+
assert.equal(r.breakthroughId, 'bk:happy');
|
|
98
|
+
assert.equal(r.edgeIds.length, 2);
|
|
99
|
+
|
|
100
|
+
// Verify the breakthrough node landed.
|
|
101
|
+
const bkRow = db.prepare("SELECT id, type, properties FROM nodes WHERE id = 'bk:happy'").get();
|
|
102
|
+
assert.equal(bkRow.type, 'breakthrough');
|
|
103
|
+
const bkProps = JSON.parse(bkRow.properties);
|
|
104
|
+
assert.equal(bkProps.kind, 'convergence');
|
|
105
|
+
assert.equal(bkProps.confidence, 0.55);
|
|
106
|
+
|
|
107
|
+
// Verify both DERIVED_FROM edges landed.
|
|
108
|
+
const edgeCount = db.prepare(
|
|
109
|
+
"SELECT COUNT(*) AS c FROM edges WHERE source = 'bk:happy' AND type = 'DERIVED_FROM'"
|
|
110
|
+
).get().c;
|
|
111
|
+
assert.equal(edgeCount, 2, 'two DERIVED_FROM edges expected');
|
|
112
|
+
|
|
113
|
+
try { closeRoomDb(db); } catch (_e) { /* graceful */ }
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('120-00 Task 3 Test 6: writeBreakthrough rejects provenance-less input', () => {
|
|
117
|
+
const { dir, db } = makeTmpDb('p120-bk-bad-');
|
|
118
|
+
const r = schema.writeBreakthrough(db, { id: 'bk:bad', artifact_ids: [] });
|
|
119
|
+
assert.equal(r.ok, false);
|
|
120
|
+
assert.equal(r.reason, 'provenance_required');
|
|
121
|
+
|
|
122
|
+
// Verify NO breakthrough node landed.
|
|
123
|
+
const bkRow = db.prepare("SELECT id FROM nodes WHERE id = 'bk:bad'").get();
|
|
124
|
+
assert.equal(bkRow, undefined, 'no breakthrough node should land');
|
|
125
|
+
|
|
126
|
+
try { closeRoomDb(db); } catch (_e) { /* graceful */ }
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('120-00 Task 3 Test 7: D-20 CONSTITUTIONAL TEST -- Cypher invariant SQL query', () => {
|
|
130
|
+
const { dir, db } = makeTmpDb('p120-bk-d20-');
|
|
131
|
+
seedArtifact(db, 'a1');
|
|
132
|
+
seedArtifact(db, 'a2');
|
|
133
|
+
const r = schema.writeBreakthrough(db, {
|
|
134
|
+
id: 'bk:d20',
|
|
135
|
+
kind: 'convergence',
|
|
136
|
+
confidence: 0.55,
|
|
137
|
+
artifact_ids: ['a1', 'a2'],
|
|
138
|
+
});
|
|
139
|
+
assert.equal(r.ok, true);
|
|
140
|
+
// The SQL equivalent of:
|
|
141
|
+
// MATCH (b:Breakthrough)-[:DERIVED_FROM]->(a:Artifact) WHERE b.id = 'bk:d20'
|
|
142
|
+
// RETURN count(a)
|
|
143
|
+
// -- must return >= 1.
|
|
144
|
+
const count = db.prepare(
|
|
145
|
+
"SELECT COUNT(*) AS c FROM edges WHERE source = 'bk:d20' AND type = 'DERIVED_FROM'"
|
|
146
|
+
).get().c;
|
|
147
|
+
assert.equal(count >= 1, true, 'D-20 invariant: count=' + count + ', expected >= 1');
|
|
148
|
+
|
|
149
|
+
try { closeRoomDb(db); } catch (_e) { /* graceful */ }
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('120-00 Task 3 Test 8: atomic rollback on writeEdge failure -- partial state cannot land', () => {
|
|
153
|
+
const { dir, db } = makeTmpDb('p120-bk-rb-');
|
|
154
|
+
seedArtifact(db, 'a-exists');
|
|
155
|
+
// a-missing intentionally NOT seeded -- FK constraint will fail on the second edge insert.
|
|
156
|
+
const r = schema.writeBreakthrough(db, {
|
|
157
|
+
id: 'bk:partial',
|
|
158
|
+
kind: 'convergence',
|
|
159
|
+
confidence: 0.55,
|
|
160
|
+
artifact_ids: ['a-exists', 'a-missing'],
|
|
161
|
+
});
|
|
162
|
+
assert.equal(r.ok, false, 'writeBreakthrough should fail when second edge target missing');
|
|
163
|
+
assert.match(r.reason, /writeEdge_failed|edge_write_failed/, 'reason should reference edge failure');
|
|
164
|
+
|
|
165
|
+
// Verify NO breakthrough node landed (ROLLBACK).
|
|
166
|
+
const bkRow = db.prepare("SELECT id FROM nodes WHERE id = 'bk:partial'").get();
|
|
167
|
+
assert.equal(bkRow, undefined, 'transaction rolled back -- no node landed');
|
|
168
|
+
|
|
169
|
+
// Verify NO DERIVED_FROM edges landed.
|
|
170
|
+
const edgeCount = db.prepare(
|
|
171
|
+
"SELECT COUNT(*) AS c FROM edges WHERE source = 'bk:partial'"
|
|
172
|
+
).get().c;
|
|
173
|
+
assert.equal(edgeCount, 0, 'transaction rolled back -- zero edges landed');
|
|
174
|
+
|
|
175
|
+
try { closeRoomDb(db); } catch (_e) { /* graceful */ }
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('120-00 Task 3 Test 9: initial surfaced flag is false', () => {
|
|
179
|
+
const { dir, db } = makeTmpDb('p120-bk-sfc-');
|
|
180
|
+
seedArtifact(db, 'a1');
|
|
181
|
+
const r = schema.writeBreakthrough(db, {
|
|
182
|
+
id: 'bk:surf',
|
|
183
|
+
kind: 'convergence',
|
|
184
|
+
confidence: 0.55,
|
|
185
|
+
artifact_ids: ['a1'],
|
|
186
|
+
});
|
|
187
|
+
assert.equal(r.ok, true);
|
|
188
|
+
const row = db.prepare("SELECT properties FROM nodes WHERE id = 'bk:surf'").get();
|
|
189
|
+
const props = JSON.parse(row.properties);
|
|
190
|
+
assert.equal(props.surfaced, false, 'surfaced flag initially false; flipped by Plan 120-02 scanner');
|
|
191
|
+
|
|
192
|
+
try { closeRoomDb(db); } catch (_e) { /* graceful */ }
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('120-00 Task 3 Test 10: D-20 BATCH INVARIANT -- zero orphaned breakthroughs', () => {
|
|
196
|
+
const { dir, db } = makeTmpDb('p120-bk-batch-');
|
|
197
|
+
seedArtifact(db, 'a1');
|
|
198
|
+
seedArtifact(db, 'a2');
|
|
199
|
+
seedArtifact(db, 'a3');
|
|
200
|
+
// Two successful writes.
|
|
201
|
+
assert.equal(schema.writeBreakthrough(db, { id: 'bk:1', kind: 'convergence', confidence: 0.55, artifact_ids: ['a1', 'a2'] }).ok, true);
|
|
202
|
+
assert.equal(schema.writeBreakthrough(db, { id: 'bk:2', kind: 'convergence', confidence: 0.55, artifact_ids: ['a3'] }).ok, true);
|
|
203
|
+
// One failed write (provenance-less).
|
|
204
|
+
assert.equal(schema.writeBreakthrough(db, { id: 'bk:3', artifact_ids: [] }).ok, false);
|
|
205
|
+
// One failed write (FK violation).
|
|
206
|
+
assert.equal(schema.writeBreakthrough(db, { id: 'bk:4', kind: 'convergence', confidence: 0.55, artifact_ids: ['a-missing'] }).ok, false);
|
|
207
|
+
|
|
208
|
+
// The batch invariant: every breakthrough node has >= 1 DERIVED_FROM edge.
|
|
209
|
+
const orphans = db.prepare(
|
|
210
|
+
"SELECT b.id FROM nodes b WHERE b.type = 'breakthrough' " +
|
|
211
|
+
"AND NOT EXISTS (SELECT 1 FROM edges e WHERE e.source = b.id AND e.type = 'DERIVED_FROM')"
|
|
212
|
+
).all();
|
|
213
|
+
assert.equal(orphans.length, 0, 'D-20 batch invariant: orphaned breakthroughs=' + JSON.stringify(orphans));
|
|
214
|
+
|
|
215
|
+
try { closeRoomDb(db); } catch (_e) { /* graceful */ }
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('120-00 Task 3 Test 11: Canon Part 8 invariant -- zero Brain client require', () => {
|
|
219
|
+
const src = fs.readFileSync(path.join(REPO_ROOT, 'lib', 'core', 'breakthrough', 'schema.cjs'), 'utf8');
|
|
220
|
+
assert.equal(/require\([^)]*brain-client/.test(src), false);
|
|
221
|
+
assert.equal(/fetch\([^)]*brain\.mindrian/.test(src), false);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('120-00 Task 3 Test 12: writeBreakthrough does NOT emit breakthrough_surfaced', () => {
|
|
225
|
+
const { dir, db } = makeTmpDb('p120-bk-no-surf-');
|
|
226
|
+
seedArtifact(db, 'a1');
|
|
227
|
+
const r = schema.writeBreakthrough(db, {
|
|
228
|
+
id: 'bk:no-surf',
|
|
229
|
+
kind: 'convergence',
|
|
230
|
+
confidence: 0.55,
|
|
231
|
+
artifact_ids: ['a1'],
|
|
232
|
+
});
|
|
233
|
+
assert.equal(r.ok, true);
|
|
234
|
+
// Verify no memory_event with event_type='breakthrough_surfaced' landed.
|
|
235
|
+
const surfacedRows = db.prepare(
|
|
236
|
+
"SELECT id FROM nodes WHERE type = 'memory_event' " +
|
|
237
|
+
"AND json_extract(properties, '$.event_type') = 'breakthrough_surfaced'"
|
|
238
|
+
).all();
|
|
239
|
+
assert.equal(surfacedRows.length, 0, 'writeBreakthrough must NOT emit breakthrough_surfaced; Plan 120-02 scanner does that');
|
|
240
|
+
|
|
241
|
+
try { closeRoomDb(db); } catch (_e) { /* graceful */ }
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('120-00 Task 3 Test 13 bonus: writeBreakthrough validates db handle', () => {
|
|
245
|
+
const r = schema.writeBreakthrough(null, { id: 'bk:x', artifact_ids: ['a1'] });
|
|
246
|
+
assert.equal(r.ok, false);
|
|
247
|
+
assert.equal(r.reason, 'invalid_db');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('120-00 Task 3 Test 14 bonus: BREAKTHROUGH_KIND constants', () => {
|
|
251
|
+
assert.equal(schema.BREAKTHROUGH_KIND.CONVERGENCE, 'convergence');
|
|
252
|
+
assert.equal(schema.BREAKTHROUGH_KIND.CONTRADICTION_RESOLVED, 'contradiction_resolved');
|
|
253
|
+
assert.equal(schema.BREAKTHROUGH_KIND.CROSS_DOMAIN_ANALOGY, 'cross_domain_analogy');
|
|
254
|
+
assert.equal(schema.BREAKTHROUGH_KIND.REVERSE_SALIENT_CLOSED, 'reverse_salient_closed');
|
|
255
|
+
assert.equal(schema.BREAKTHROUGH_NODE_TYPE, 'breakthrough');
|
|
256
|
+
});
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* Phase 120-01 Wave 2 Task 3 -- the 5-component breakthrough scoring formula +
|
|
4
|
+
* rankBreakthroughs + pickTopWithAffordance + getUserEngagementPrior +
|
|
5
|
+
* isThrottledKind. Per CONTEXT.md D-11..D-12 + D-19 verbatim:
|
|
6
|
+
*
|
|
7
|
+
* score = (confidence x 0.4)
|
|
8
|
+
* + (recency_decay x 0.2) // half-life ~3 days
|
|
9
|
+
* + (differential x 0.2)
|
|
10
|
+
* + (artifact_count_log x 0.1) // clamped [0, 1]
|
|
11
|
+
* + (user_engagement_prior x 0.1) // per-detector-type Laplace-smoothed prior
|
|
12
|
+
*
|
|
13
|
+
* Tunable via config.json; the weights are the documented defaults (NOT magic
|
|
14
|
+
* numbers). Sum of weights MUST equal 1.0 (verified by SCORING_WEIGHTS unit test).
|
|
15
|
+
*
|
|
16
|
+
* Canon Part 8: ALL reads from breakthrough_confirmed / breakthrough_dismissed
|
|
17
|
+
* / breakthrough_surfaced events stay LOCAL to the room.db. NO cross-room
|
|
18
|
+
* aggregation. NO Brain coupling. NO require of brain-client. NO fetch to
|
|
19
|
+
* brain.mindrian.* domain.
|
|
20
|
+
*
|
|
21
|
+
* Canon Part 9: ALL reads route through navigation.findRecentChanges chokepoint.
|
|
22
|
+
* No direct sqlite reads in scoring.cjs (defense in depth on top of room-db.cjs
|
|
23
|
+
* allow-list). Test 12 source-greps this invariant.
|
|
24
|
+
*
|
|
25
|
+
* Canon Part 10 sub-claim 5: variable reward fires automatically; the score IS
|
|
26
|
+
* the math. The 5 components are deterministic functions of room state -- no
|
|
27
|
+
* stochastic surfacing, no engagement-optimizer drift.
|
|
28
|
+
*
|
|
29
|
+
* Em-dash HARD RULE (CLAUDE.md feedback_no_emdashes): use "--" not the U+2014
|
|
30
|
+
* character anywhere in this file (comments, log lines, strings).
|
|
31
|
+
*
|
|
32
|
+
* Pure CJS, node built-ins only, zero deps (Phase 87 invariant). Reads route
|
|
33
|
+
* through ../navigation.cjs::findRecentChanges (the only Canon Part 9-compliant
|
|
34
|
+
* door for memory_event queries).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const navigation = require('../navigation.cjs');
|
|
38
|
+
|
|
39
|
+
// CONTEXT.md D-12 verbatim lock. Object.freeze prevents accidental mutation of
|
|
40
|
+
// the canonical weight map; if a downstream caller needs a mutable copy they
|
|
41
|
+
// must clone explicitly. The 5 weights MUST sum to exactly 1.0 (Test 1 asserts
|
|
42
|
+
// this within 1e-9 floating-point tolerance).
|
|
43
|
+
const SCORING_WEIGHTS = Object.freeze({
|
|
44
|
+
confidence: 0.4,
|
|
45
|
+
recency: 0.2,
|
|
46
|
+
differential: 0.2,
|
|
47
|
+
artifact_count_log: 0.1,
|
|
48
|
+
user_engagement_prior: 0.1,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// CONTEXT.md D-12 verbatim: recency half-life ~3 days. The exponential-decay
|
|
52
|
+
// kernel is exp(-ageDays / RECENCY_HALF_LIFE_DAYS); 3-day-old fires decay to
|
|
53
|
+
// exp(-1) ~= 0.368, 6-day-old fires decay to exp(-2) ~= 0.135.
|
|
54
|
+
const RECENCY_HALF_LIFE_DAYS = 3;
|
|
55
|
+
|
|
56
|
+
// Neutral-prior return value when getUserEngagementPrior has no history to
|
|
57
|
+
// learn from (or graceful degradation path on chokepoint failure). The +0.5
|
|
58
|
+
// Laplace smoothing pivots around this midpoint.
|
|
59
|
+
const ENGAGEMENT_NEUTRAL_PRIOR = 0.5;
|
|
60
|
+
|
|
61
|
+
// CONTEXT.md D-19 verbatim: per-detector dismissal-rate canary. If a detector's
|
|
62
|
+
// dismiss rate over the rolling D19_FIRE_WINDOW most-recent fires exceeds
|
|
63
|
+
// D19_DISMISSAL_THRESHOLD, isThrottledKind returns true (Plan 120-02 scanner
|
|
64
|
+
// then auto-throttles that detector to soft-fire-only until manually reviewed).
|
|
65
|
+
//
|
|
66
|
+
// D19_MIN_SAMPLE prevents premature throttling on small populations: a 1-of-2
|
|
67
|
+
// dismiss = 50% would otherwise trip the canary on essentially noise. The
|
|
68
|
+
// floor of 10 ensures statistical adequacy before throttling kicks in.
|
|
69
|
+
const D19_DISMISSAL_THRESHOLD = 0.30;
|
|
70
|
+
const D19_FIRE_WINDOW = 100;
|
|
71
|
+
const D19_MIN_SAMPLE = 10;
|
|
72
|
+
|
|
73
|
+
// Per-kind engagement-prior + canary window. Reads memory_event rows from the
|
|
74
|
+
// trailing 90 days; older history is considered stale and excluded from the
|
|
75
|
+
// learning signal.
|
|
76
|
+
const PRIOR_WINDOW_DAYS = 90;
|
|
77
|
+
const PRIOR_QUERY_LIMIT = 500;
|
|
78
|
+
|
|
79
|
+
const DAY_MS = 24 * 3600 * 1000;
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// recencyDecay -- exp(-ageDays / RECENCY_HALF_LIFE_DAYS). Clamped to [0, 1].
|
|
83
|
+
// Future-dated candidates (ageMs < 0) clamp to age=0 (decay=1.0); they never
|
|
84
|
+
// produce a decay > 1.0 even on clock skew.
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
function recencyDecay(detected_at, nowMs) {
|
|
87
|
+
const now = (typeof nowMs === 'number' && Number.isFinite(nowMs)) ? nowMs : Date.now();
|
|
88
|
+
const ts = (typeof detected_at === 'number' && Number.isFinite(detected_at)) ? detected_at : now;
|
|
89
|
+
const ageMs = Math.max(0, now - ts);
|
|
90
|
+
const ageDays = ageMs / DAY_MS;
|
|
91
|
+
const decay = Math.exp(-ageDays / RECENCY_HALF_LIFE_DAYS);
|
|
92
|
+
// Defense in depth: clamp to [0, 1] in case of floating-point edge cases.
|
|
93
|
+
return Math.min(1, Math.max(0, decay));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// artifactCountLog -- log10(N) clamped to [0, 1]. CONTEXT.md D-12 specifies
|
|
98
|
+
// the component is bounded so a 100-artifact convergence can't dominate the
|
|
99
|
+
// score. log10(1)=0, log10(10)=1.0 (the ceiling), log10(100)=2.0 -> clamped 1.
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
function artifactCountLog(artifact_ids) {
|
|
102
|
+
if (!Array.isArray(artifact_ids)) return 0;
|
|
103
|
+
const n = artifact_ids.length;
|
|
104
|
+
if (n <= 0) return 0;
|
|
105
|
+
const raw = Math.log10(n);
|
|
106
|
+
return Math.min(1, Math.max(0, raw));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// getUserEngagementPrior -- per-detector-kind Laplace-smoothed prior in [0, 1].
|
|
111
|
+
//
|
|
112
|
+
// Reads via navigation.findRecentChanges (Canon Part 9 chokepoint). Filters
|
|
113
|
+
// the recent breakthrough_confirmed + breakthrough_dismissed events by kind
|
|
114
|
+
// (extracted from properties.kind on each memory_event row).
|
|
115
|
+
//
|
|
116
|
+
// Smoothing formula: (confirmed + 0.5) / (confirmed + dismissed + 1)
|
|
117
|
+
// - empty history (0 confirmed + 0 dismissed): 0.5/1 = 0.5 (neutral)
|
|
118
|
+
// - 3 confirmed + 0 dismissed: 3.5/4 = 0.875 (pushed up)
|
|
119
|
+
// - 1 confirmed + 4 dismissed: 1.5/6 = 0.25 (pushed down)
|
|
120
|
+
//
|
|
121
|
+
// The +0.5 / +1 form pivots around the neutral midpoint and avoids the
|
|
122
|
+
// division-by-zero failure mode of a naive ratio.
|
|
123
|
+
//
|
|
124
|
+
// Graceful degradation: missing/invalid roomState -> neutral 0.5. Chokepoint
|
|
125
|
+
// throw (e.g., db handle closed) -> neutral 0.5. The prior must NEVER throw
|
|
126
|
+
// from scoreBreakthrough's hot path.
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
function getUserEngagementPrior(kind, roomState) {
|
|
129
|
+
try {
|
|
130
|
+
if (!roomState || !roomState.db) return ENGAGEMENT_NEUTRAL_PRIOR;
|
|
131
|
+
const since = Date.now() - (PRIOR_WINDOW_DAYS * DAY_MS);
|
|
132
|
+
const confirmed = navigation.findRecentChanges(roomState.db, since, {
|
|
133
|
+
eventType: 'breakthrough_confirmed',
|
|
134
|
+
limit: PRIOR_QUERY_LIMIT,
|
|
135
|
+
}) || [];
|
|
136
|
+
const dismissed = navigation.findRecentChanges(roomState.db, since, {
|
|
137
|
+
eventType: 'breakthrough_dismissed',
|
|
138
|
+
limit: PRIOR_QUERY_LIMIT,
|
|
139
|
+
}) || [];
|
|
140
|
+
const cConfirmed = confirmed.filter((e) => e.properties && e.properties.kind === kind).length;
|
|
141
|
+
const cDismissed = dismissed.filter((e) => e.properties && e.properties.kind === kind).length;
|
|
142
|
+
return (cConfirmed + 0.5) / (cConfirmed + cDismissed + 1);
|
|
143
|
+
} catch (_e) {
|
|
144
|
+
return ENGAGEMENT_NEUTRAL_PRIOR;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// isThrottledKind -- CONTEXT.md D-19 dismissal-rate canary.
|
|
150
|
+
//
|
|
151
|
+
// Returns true if the per-kind dismissal rate over the rolling D19_FIRE_WINDOW
|
|
152
|
+
// most-recent fires exceeds D19_DISMISSAL_THRESHOLD (with D19_MIN_SAMPLE floor
|
|
153
|
+
// to avoid throttling on small populations).
|
|
154
|
+
//
|
|
155
|
+
// Implementation:
|
|
156
|
+
// 1. Read breakthrough_surfaced + breakthrough_dismissed events for the kind
|
|
157
|
+
// via navigation.findRecentChanges (Canon Part 9 chokepoint).
|
|
158
|
+
// 2. Take the most-recent D19_FIRE_WINDOW surfaces for the kind.
|
|
159
|
+
// 3. Match dismisses against the surfaced breakthrough_ids (only dismisses
|
|
160
|
+
// that targeted a fire in the window count).
|
|
161
|
+
// 4. If fired.length < D19_MIN_SAMPLE, return false (statistical floor).
|
|
162
|
+
// 5. Compute rate = dismissed_in_window / fired.length. Return rate > D19_DISMISSAL_THRESHOLD.
|
|
163
|
+
//
|
|
164
|
+
// Graceful degradation: missing/invalid roomState -> false (not throttled).
|
|
165
|
+
// Chokepoint throw -> false (fail-open; never lock out a detector on infra hiccup).
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
function isThrottledKind(kind, roomState) {
|
|
168
|
+
try {
|
|
169
|
+
if (!roomState || !roomState.db) return false;
|
|
170
|
+
const since = Date.now() - (PRIOR_WINDOW_DAYS * DAY_MS);
|
|
171
|
+
const surfaced = navigation.findRecentChanges(roomState.db, since, {
|
|
172
|
+
eventType: 'breakthrough_surfaced',
|
|
173
|
+
limit: PRIOR_QUERY_LIMIT,
|
|
174
|
+
}) || [];
|
|
175
|
+
const dismissed = navigation.findRecentChanges(roomState.db, since, {
|
|
176
|
+
eventType: 'breakthrough_dismissed',
|
|
177
|
+
limit: PRIOR_QUERY_LIMIT,
|
|
178
|
+
}) || [];
|
|
179
|
+
// Filter by kind and take most-recent D19_FIRE_WINDOW fires (findRecentChanges
|
|
180
|
+
// already returns rows ORDER BY created_at DESC).
|
|
181
|
+
const fired = surfaced
|
|
182
|
+
.filter((e) => e.properties && e.properties.kind === kind)
|
|
183
|
+
.slice(0, D19_FIRE_WINDOW);
|
|
184
|
+
if (fired.length < D19_MIN_SAMPLE) return false;
|
|
185
|
+
// Match dismisses against the in-window fire ids. Only dismisses that target
|
|
186
|
+
// a fire in the window count; a dismiss for an out-of-window fire is noise.
|
|
187
|
+
const fireIds = new Set(
|
|
188
|
+
fired
|
|
189
|
+
.map((e) => e.properties && e.properties.breakthrough_id)
|
|
190
|
+
.filter((id) => typeof id === 'string' && id.length > 0)
|
|
191
|
+
);
|
|
192
|
+
const dismissedInWindow = dismissed.filter(
|
|
193
|
+
(e) => e.properties && e.properties.kind === kind &&
|
|
194
|
+
typeof e.properties.breakthrough_id === 'string' &&
|
|
195
|
+
fireIds.has(e.properties.breakthrough_id)
|
|
196
|
+
).length;
|
|
197
|
+
const rate = dismissedInWindow / fired.length;
|
|
198
|
+
return rate > D19_DISMISSAL_THRESHOLD;
|
|
199
|
+
} catch (_e) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// scoreBreakthrough -- the 5-component formula (CONTEXT.md D-12 verbatim).
|
|
206
|
+
//
|
|
207
|
+
// score = W.confidence * candidate.confidence
|
|
208
|
+
// + W.recency * recencyDecay(candidate.detected_at, nowMs)
|
|
209
|
+
// + W.differential * candidate.differential
|
|
210
|
+
// + W.artifact_count_log * artifactCountLog(candidate.artifact_ids)
|
|
211
|
+
// + W.user_engagement_prior * getUserEngagementPrior(candidate.kind, roomState)
|
|
212
|
+
//
|
|
213
|
+
// Each component is independently bounded; the weighted sum is bounded in [0, 1]
|
|
214
|
+
// (assuming candidate.confidence and candidate.differential are themselves in
|
|
215
|
+
// [0, 1] -- the detectors enforce this via the buildCandidate clip in Plan 120-00).
|
|
216
|
+
//
|
|
217
|
+
// Graceful degradation: null/non-object candidate -> 0. Missing fields default
|
|
218
|
+
// to 0 (or to nowMs for detected_at -> decay=1.0). The function must NEVER throw.
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
function scoreBreakthrough(candidate, roomState, nowMs) {
|
|
221
|
+
if (!candidate || typeof candidate !== 'object') return 0;
|
|
222
|
+
const conf = (typeof candidate.confidence === 'number') ? candidate.confidence : 0;
|
|
223
|
+
const diff = (typeof candidate.differential === 'number') ? candidate.differential : 0;
|
|
224
|
+
const now = (typeof nowMs === 'number' && Number.isFinite(nowMs)) ? nowMs : Date.now();
|
|
225
|
+
const recency = recencyDecay(
|
|
226
|
+
(typeof candidate.detected_at === 'number') ? candidate.detected_at : now,
|
|
227
|
+
now
|
|
228
|
+
);
|
|
229
|
+
const acLog = artifactCountLog(candidate.artifact_ids);
|
|
230
|
+
const engagement = getUserEngagementPrior(candidate.kind, roomState);
|
|
231
|
+
const W = SCORING_WEIGHTS;
|
|
232
|
+
return (W.confidence * conf)
|
|
233
|
+
+ (W.recency * recency)
|
|
234
|
+
+ (W.differential * diff)
|
|
235
|
+
+ (W.artifact_count_log * acLog)
|
|
236
|
+
+ (W.user_engagement_prior * engagement);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// rankBreakthroughs -- stable sort by score descending; ties broken by
|
|
241
|
+
// detected_at descending (newer wins). Each ranked item is shallow-cloned with
|
|
242
|
+
// a numeric `score` property attached.
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
function rankBreakthroughs(candidates, roomState, nowMs) {
|
|
245
|
+
if (!Array.isArray(candidates)) return [];
|
|
246
|
+
const now = (typeof nowMs === 'number' && Number.isFinite(nowMs)) ? nowMs : Date.now();
|
|
247
|
+
const scored = candidates.map(function (c) {
|
|
248
|
+
return { candidate: c, score: scoreBreakthrough(c, roomState, now) };
|
|
249
|
+
});
|
|
250
|
+
scored.sort(function (a, b) {
|
|
251
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
252
|
+
// Tie-break: newer detected_at wins (descending). Missing detected_at sorts
|
|
253
|
+
// last (treat as 0).
|
|
254
|
+
const aTs = (a.candidate && typeof a.candidate.detected_at === 'number') ? a.candidate.detected_at : 0;
|
|
255
|
+
const bTs = (b.candidate && typeof b.candidate.detected_at === 'number') ? b.candidate.detected_at : 0;
|
|
256
|
+
return bTs - aTs;
|
|
257
|
+
});
|
|
258
|
+
return scored.map(function (s) {
|
|
259
|
+
return Object.assign({}, s.candidate, { score: s.score });
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// pickTopWithAffordance -- CONTEXT.md D-11 verbatim: surface the top-1; expose
|
|
265
|
+
// the rest as a "More breakthroughs (N)" affordance count + the queued list.
|
|
266
|
+
//
|
|
267
|
+
// Returns { top: <highest-scoring | null>, more_count: <N | 0>, queued: <[]> }.
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
function pickTopWithAffordance(candidates, roomState, nowMs) {
|
|
270
|
+
const ranked = rankBreakthroughs(candidates, roomState, nowMs);
|
|
271
|
+
if (ranked.length === 0) return { top: null, more_count: 0, queued: [] };
|
|
272
|
+
return {
|
|
273
|
+
top: ranked[0],
|
|
274
|
+
more_count: ranked.length - 1,
|
|
275
|
+
queued: ranked.slice(1),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
module.exports = {
|
|
280
|
+
scoreBreakthrough: scoreBreakthrough,
|
|
281
|
+
rankBreakthroughs: rankBreakthroughs,
|
|
282
|
+
pickTopWithAffordance: pickTopWithAffordance,
|
|
283
|
+
getUserEngagementPrior: getUserEngagementPrior,
|
|
284
|
+
isThrottledKind: isThrottledKind,
|
|
285
|
+
recencyDecay: recencyDecay,
|
|
286
|
+
artifactCountLog: artifactCountLog,
|
|
287
|
+
SCORING_WEIGHTS: SCORING_WEIGHTS,
|
|
288
|
+
RECENCY_HALF_LIFE_DAYS: RECENCY_HALF_LIFE_DAYS,
|
|
289
|
+
ENGAGEMENT_NEUTRAL_PRIOR: ENGAGEMENT_NEUTRAL_PRIOR,
|
|
290
|
+
D19_DISMISSAL_THRESHOLD: D19_DISMISSAL_THRESHOLD,
|
|
291
|
+
D19_FIRE_WINDOW: D19_FIRE_WINDOW,
|
|
292
|
+
D19_MIN_SAMPLE: D19_MIN_SAMPLE,
|
|
293
|
+
};
|