phaedo-mcp 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +17 -0
- package/README.md +182 -19
- package/action-binding.js +82 -0
- package/agreement.js +86 -2
- package/ap2.js +214 -0
- package/autonomy-promotion.js +280 -0
- package/build-package.mjs +25 -10
- package/capture-a11y.js +116 -0
- package/capture-local.js +85 -0
- package/capture-sync.js +99 -0
- package/consult-core.js +126 -8
- package/consult-policy.js +427 -33
- package/consult-runtime.js +60 -0
- package/consult.js +29 -1
- package/delegation-sync.js +95 -0
- package/escalation-policy.sample.json +13 -0
- package/escalation.js +383 -0
- package/fingerprint-source.js +112 -13
- package/health.js +54 -0
- package/hooks/pre-ship.mjs +409 -0
- package/hooks/session-end.mjs +244 -0
- package/hooks/stop-capture.mjs +140 -0
- package/install.mjs +226 -36
- package/lib/capture-home.js +46 -0
- package/lib/domain.js +101 -0
- package/lib/is-main.js +40 -0
- package/lib/phaedo-crypto.js +17 -1
- package/package.json +29 -5
- package/phone-source.js +85 -12
- package/projection.js +4 -1
- package/pull.js +19 -3
- package/receipts-sync.js +197 -0
- package/receipts.js +34 -3
- package/server.js +323 -54
- package/server.json +3 -3
- package/ship-actions.js +164 -0
- package/ship-readout.js +106 -0
- package/signing-audit.js +111 -0
- package/signing-request.js +217 -0
- package/signing.js +176 -0
- package/vendor/authored-source.js +250 -0
- package/vendor/capabilities.js +203 -0
- package/vendor/capture/capture-core.js +67 -0
- package/vendor/capture/detectors.js +287 -0
- package/vendor/capture/exchanges.js +77 -0
- package/vendor/capture/model-detect.js +195 -0
- package/vendor/capture/owner.js +35 -0
- package/vendor/capture/package.json +4 -0
- package/vendor/capture/source-id.js +56 -0
- package/vendor/capture/state.js +148 -0
- package/vendor/capture/transcript.js +184 -0
- package/vendor/commitments.js +169 -0
- package/vendor/context-block.js +251 -18
- package/vendor/envelope.js +50 -4
- package/vendor/extraction/capture-combine.js +160 -0
- package/vendor/extraction/catalog.js +232 -0
- package/vendor/extraction/delta-lift.js +109 -0
- package/vendor/extraction/fingerprint-update.js +227 -0
- package/vendor/extraction/package.json +4 -0
- package/vendor/extraction/persist.js +45 -0
- package/vendor/extraction/reconcile.js +310 -0
- package/vendor/extraction/resolve.js +178 -0
- package/vendor/extraction/rule-promotion.js +103 -0
- package/vendor/kdf.js +20 -1
- package/vendor/protocol.js +7 -0
- package/vendor/signal-ledger.js +402 -0
- package/vendor/trait-phrases.js +656 -0
- package/vendored-files.mjs +86 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Phaedo Loop 2 extraction — fingerprint persist merge (the CAS-safe contract).
|
|
2
|
+
//
|
|
3
|
+
// The offscreen pass produces a raw per-layer `update` delta. Merging that delta
|
|
4
|
+
// INTO the persistent fingerprint is a read-modify-write, and it MUST run against
|
|
5
|
+
// the freshest fingerprint under a lock — otherwise a pass that read the
|
|
6
|
+
// fingerprint before it ran will blind-overwrite anything authored in between:
|
|
7
|
+
// a subject-endorsed standing_rule, another tab's accumulated signals, or a
|
|
8
|
+
// just-dismissed conflict (the extension-CAS lost-update finding).
|
|
9
|
+
//
|
|
10
|
+
// This module is the PURE core of that merge: reconcile the delta, stage any
|
|
11
|
+
// newly-promotable suggested rules, stamp updated_at. The service worker
|
|
12
|
+
// (background.js `mergeExtraction`) wraps it in `withVaultLock` against a fresh
|
|
13
|
+
// `readVault`, so the whole read → merge → write is atomic. Keeping the merge
|
|
14
|
+
// pure makes the race-safety contract node-testable without chrome.
|
|
15
|
+
|
|
16
|
+
import { reconcileFingerprint } from './reconcile.js';
|
|
17
|
+
import { promoteRules } from './rule-promotion.js';
|
|
18
|
+
import { applyCaptureSources } from './capture-combine.js';
|
|
19
|
+
|
|
20
|
+
// Reconcile `update` into `fingerprint` and return the next fingerprint. Never
|
|
21
|
+
// mutates the input. reconcile merges layer signals accumulatively and upserts
|
|
22
|
+
// conflict_records (dismissals stay sticky); promoteRules stages suggested_rules
|
|
23
|
+
// for established, not-yet-authored D&R patterns (skipping dimensions the subject
|
|
24
|
+
// already authored). Because both are pure and merge — rather than replace — the
|
|
25
|
+
// current fingerprint, running them on a FRESH read is the whole lost-update fix.
|
|
26
|
+
//
|
|
27
|
+
// `captureSources` (optional): the vault's per-surface capture snapshots
|
|
28
|
+
// (phaedo_capture_sources values). When present, the update's delta signals are
|
|
29
|
+
// replaced by the CROSS-SURFACE combined set before reconcile (capture-combine.js)
|
|
30
|
+
// — otherwise a delta write from one surface would clobber the accumulated counts
|
|
31
|
+
// of the others (delta reconcile is replace-by-key). Callers that hold a vault
|
|
32
|
+
// with capture sources MUST pass them on every delta-writing merge.
|
|
33
|
+
//
|
|
34
|
+
// `attr` (optional { sourceId, context }): which browser this pass ran in. Extraction evidence is
|
|
35
|
+
// then recorded per source (signal-ledger.js), so learning from several browsers adds up at sync
|
|
36
|
+
// instead of the newest copy replacing the rest.
|
|
37
|
+
export function mergeExtractionInto(fingerprint, update, now = new Date().toISOString(), captureSources = null, attr = null) {
|
|
38
|
+
const effective = (Array.isArray(captureSources) && captureSources.length)
|
|
39
|
+
? applyCaptureSources(update, captureSources, now)
|
|
40
|
+
: update;
|
|
41
|
+
const ropts = attr && attr.sourceId ? { now, sourceId: attr.sourceId, context: attr.context } : { now };
|
|
42
|
+
const merged = promoteRules(reconcileFingerprint(fingerprint || {}, effective, ropts), { now });
|
|
43
|
+
merged.updated_at = now; // bump for sync last-write-wins
|
|
44
|
+
return merged;
|
|
45
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// Phaedo Loop 2 extraction — reconciliation (update → fingerprint).
|
|
2
|
+
//
|
|
3
|
+
// Merges a partial fingerprint update (projection.buildFingerprintUpdate) into
|
|
4
|
+
// the stored fingerprint, per extraction-task-spec §6/§7 and §4.3:
|
|
5
|
+
//
|
|
6
|
+
// - Signals are keyed by (field, polarity). A new pass either reinforces an
|
|
7
|
+
// existing signal (same key → merge) or adds a new one.
|
|
8
|
+
// - Reinforcement raises confidence (more consistent observations = more
|
|
9
|
+
// likely a stable trait, §7), sums observation_count, unions session_refs,
|
|
10
|
+
// advances last_observed.
|
|
11
|
+
// - Anti-preference precedence (§4.3, §6): when a layer holds both a positive
|
|
12
|
+
// and a negative signal for the same field, the negative (anti-preference)
|
|
13
|
+
// is the effective one. Both are retained for provenance; effectiveSignals()
|
|
14
|
+
// applies the precedence for downstream consumers.
|
|
15
|
+
// - Non-destructive: questionnaire data on a layer (e.g. `responses[]`, the
|
|
16
|
+
// cold-start shape in §2) is preserved. Extraction only writes the §4.2
|
|
17
|
+
// signal-bearing fields. Authored User Configuration (§5) is a sibling
|
|
18
|
+
// artifact extraction never touches.
|
|
19
|
+
//
|
|
20
|
+
// NUMERIC CALIBRATION IS DEFERRED (§7): the confidence-combine curve and
|
|
21
|
+
// contradiction handling below are honest placeholders to be tuned by the eval
|
|
22
|
+
// harness once a real model exists. They are monotone and bounded, not claimed
|
|
23
|
+
// to be calibrated.
|
|
24
|
+
|
|
25
|
+
import { renderLayerSummary } from './fingerprint-update.js';
|
|
26
|
+
import { resolveDimension, valKey, authoredGovernedKeys } from './resolve.js';
|
|
27
|
+
// Per-source ledgers (multi-browser): a classic UMD file, loaded for its side effect and read off
|
|
28
|
+
// globalThis so the same file serves the popup's classic scripts, this module and Node.
|
|
29
|
+
import '../signal-ledger.js';
|
|
30
|
+
const SL = globalThis.PhaedoSignalLedger;
|
|
31
|
+
|
|
32
|
+
const CONFIDENCE_CAP = 0.95;
|
|
33
|
+
|
|
34
|
+
// Noisy-OR: independent consistent observations push confidence up, never past
|
|
35
|
+
// the cap. Placeholder pending calibration; contradiction-driven *lowering*
|
|
36
|
+
// (§7, persona-algorithm "adversarial robustness") is a deferred refinement.
|
|
37
|
+
function combineConfidence(a, b) {
|
|
38
|
+
return Math.min(CONFIDENCE_CAP, 1 - (1 - (a ?? 0)) * (1 - (b ?? 0)));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function unionArrays(a = [], b = []) {
|
|
42
|
+
return Array.from(new Set([...a, ...b]));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Merge two signal objects with the same (field, polarity) key.
|
|
46
|
+
//
|
|
47
|
+
// `attr` ({ sourceId, context }, optional): WHO produced this increment. When given, the evidence
|
|
48
|
+
// is recorded in the signal's per-source ledger (signal-ledger.js) and the totals are re-derived
|
|
49
|
+
// from it, so a second browser's learning ADDS to this one's at sync instead of replacing it.
|
|
50
|
+
// Without it (tests, the desktop fold) the behaviour is exactly the pre-ledger accumulate.
|
|
51
|
+
function mergeSignal(existing, incoming, now, attr) {
|
|
52
|
+
// Delta signals carry Path A's CUMULATIVE count — a re-lift is a fresh
|
|
53
|
+
// snapshot of the same counters, not new evidence. Replace wholesale so
|
|
54
|
+
// re-running the lift every pass is idempotent (no count/confidence
|
|
55
|
+
// inflation). Keep the stable signal_id.
|
|
56
|
+
if (incoming.source === 'delta') {
|
|
57
|
+
return { ...incoming, signal_id: existing.signal_id || incoming.signal_id };
|
|
58
|
+
}
|
|
59
|
+
if (attr && attr.sourceId && SL && SL.isLedgered(incoming)) {
|
|
60
|
+
return SL.attribute(existing, incoming, attr.sourceId, { now, context: attr.context });
|
|
61
|
+
}
|
|
62
|
+
const observation_count = (existing.observation_count || 1) + (incoming.observation_count || 1);
|
|
63
|
+
// Value merge: arrays union; scalar/enum/string keep the higher-confidence
|
|
64
|
+
// value (a contradiction across passes resolves to the better-supported one).
|
|
65
|
+
let value = existing.value;
|
|
66
|
+
if (Array.isArray(existing.value) && Array.isArray(incoming.value)) {
|
|
67
|
+
value = unionArrays(existing.value, incoming.value);
|
|
68
|
+
} else if ((incoming.confidence ?? 0) > (existing.confidence ?? 0)) {
|
|
69
|
+
value = incoming.value;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
...existing,
|
|
73
|
+
// A questionnaire (self-reported) signal that observation now corroborates is
|
|
74
|
+
// no longer "stated only" — adopt the observing source so it counts as
|
|
75
|
+
// learned (and surfaces in the §5b summary). Pure self-report keeps
|
|
76
|
+
// source 'questionnaire' and stays out of the summary.
|
|
77
|
+
source: incoming.source || existing.source,
|
|
78
|
+
value,
|
|
79
|
+
confidence: Number(combineConfidence(existing.confidence, incoming.confidence).toFixed(3)),
|
|
80
|
+
observation_count,
|
|
81
|
+
session_refs: unionArrays(existing.session_refs, incoming.session_refs),
|
|
82
|
+
last_observed: maxIso(existing.last_observed, incoming.last_observed) || now,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function maxIso(a, b) {
|
|
87
|
+
if (!a) return b;
|
|
88
|
+
if (!b) return a;
|
|
89
|
+
return a > b ? a : b;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Dedupe key. Domain-scoped Decision & Risk signals (§4.2.7 optional `domain`)
|
|
93
|
+
// reconcile per (field, polarity, domain) so "financial caution" and "writing
|
|
94
|
+
// boldness" coexist and reinforce independently; an untagged signal keeps the
|
|
95
|
+
// legacy key — fully backward-compatible.
|
|
96
|
+
const sigKey = s => `${s.field}::${s.polarity}${typeof s.domain === 'string' ? `::${s.domain}` : ''}`;
|
|
97
|
+
|
|
98
|
+
// Reconcile one incoming layer doc into an existing layer doc (or undefined).
|
|
99
|
+
// Returns a new layer object; does not mutate inputs. `suppress` (optional Set of
|
|
100
|
+
// "field"/"field::domain") withholds those dimensions from the rendered summary —
|
|
101
|
+
// the §4.7 injection-suppression of an open-conflict dimension. `attr` — see mergeSignal.
|
|
102
|
+
export function reconcileLayer(existingLayer, incomingLayer, now, suppress, attr) {
|
|
103
|
+
const base = existingLayer ? { ...existingLayer } : {};
|
|
104
|
+
const existingSignals = Array.isArray(base.signals) ? base.signals : [];
|
|
105
|
+
const byKey = new Map(existingSignals.map(s => [sigKey(s), s]));
|
|
106
|
+
const attributed = !!(attr && attr.sourceId && SL);
|
|
107
|
+
|
|
108
|
+
for (const inc of incomingLayer.signals || []) {
|
|
109
|
+
const k = sigKey(inc);
|
|
110
|
+
if (byKey.has(k)) byKey.set(k, mergeSignal(byKey.get(k), inc, now, attr));
|
|
111
|
+
// A NEW signal is attributed too, or the same signal first seen independently on two
|
|
112
|
+
// browsers would carry no ledger on either and merge last-write-wins.
|
|
113
|
+
else byKey.set(k, attributed && SL.isLedgered(inc) ? SL.attribute(null, inc, attr.sourceId, { now, context: attr.context }) : inc);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const mergedSignals = Array.from(byKey.values());
|
|
117
|
+
return {
|
|
118
|
+
...base, // preserve questionnaire `responses`, `label`, etc.
|
|
119
|
+
layer_version: incomingLayer.layer_version || base.layer_version || '0.1',
|
|
120
|
+
last_updated: now,
|
|
121
|
+
confidence: Number((mergedSignals.reduce((a, s) => a + (s.confidence || 0), 0) /
|
|
122
|
+
(mergedSignals.length || 1)).toFixed(3)),
|
|
123
|
+
signals: mergedSignals,
|
|
124
|
+
// §5b "Learned from your conversations" should reflect observation, not
|
|
125
|
+
// restate the interview (§5 already renders questionnaire answers). Render
|
|
126
|
+
// only observation-backed signals; pure self-report (source 'questionnaire',
|
|
127
|
+
// never corroborated) stays in signals[] for provenance but is excluded here.
|
|
128
|
+
summary: renderLayerSummary(effectiveSignals(mergedSignals).filter(s => s.source !== 'questionnaire'), suppress),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Apply §4.3/§6 anti-preference precedence: if a field has a negative signal,
|
|
133
|
+
// the positive one for that field is suppressed from the effective set (kept in
|
|
134
|
+
// storage for provenance, dropped from the rendered/consumed view).
|
|
135
|
+
export function effectiveSignals(signals) {
|
|
136
|
+
const negFields = new Set(signals.filter(s => s.polarity === 'negative').map(s => s.field));
|
|
137
|
+
return signals.filter(s => !(s.polarity === 'positive' && negFields.has(s.field)));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── Conflict records (the update gate's "contradict" outcome) ────────────────
|
|
141
|
+
//
|
|
142
|
+
// mergeSignal() above collapses same-(field,polarity,domain) signals by confidence,
|
|
143
|
+
// so a genuine contradiction — a new OBSERVED value that disagrees with the subject's
|
|
144
|
+
// SELF-REPORT while the observed evidence is still thin (< §4.7 floor) — would vanish
|
|
145
|
+
// silently. Detect it at the merge moment (both values still visible), classify it
|
|
146
|
+
// with the SHARED §4.7 resolver, and stage a durable, reviewable record. This is the
|
|
147
|
+
// producer-written `suggested_rules` pattern (Proposal 0007): never injected, never
|
|
148
|
+
// consulted — a queue the subject reviews. We do NOT change what is stored or injected
|
|
149
|
+
// here (that injection-suppression fix is a separate, behaviour-changing follow-up);
|
|
150
|
+
// this only makes the contradiction visible and keeps both sides' provenance.
|
|
151
|
+
|
|
152
|
+
const uuid = () =>
|
|
153
|
+
(typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID()
|
|
154
|
+
: 'cfl-' + Math.random().toString(36).slice(2, 10);
|
|
155
|
+
|
|
156
|
+
const conflictKey = (layer, field, domain) =>
|
|
157
|
+
`${layer}::${field}${typeof domain === 'string' ? `::${domain}` : ''}`;
|
|
158
|
+
|
|
159
|
+
// Compare an incoming layer's signals against the existing ones BEFORE the merge
|
|
160
|
+
// collapses them. For each incoming signal that shares a (field,polarity,domain) key
|
|
161
|
+
// with an existing one but carries a DIFFERENT scalar/enum value, ask the §4.7 resolver:
|
|
162
|
+
// - status "unresolved" (thin inferred value vs a self-report) → an OPEN conflict.
|
|
163
|
+
// - status "resolved" (the inferred side crossed the floor, or observed-vs-observed) →
|
|
164
|
+
// the dimension now settles; if a record was open for it, CLOSE it.
|
|
165
|
+
// Array-valued fields union (never conflict) and are skipped. Pure; no mutation.
|
|
166
|
+
export function detectConflicts(existingSignals, incomingSignals) {
|
|
167
|
+
const byKey = new Map((Array.isArray(existingSignals) ? existingSignals : []).map((s) => [sigKey(s), s]));
|
|
168
|
+
const open = [], settled = [], overrides = [];
|
|
169
|
+
for (const inc of (Array.isArray(incomingSignals) ? incomingSignals : [])) {
|
|
170
|
+
const ex = byKey.get(sigKey(inc));
|
|
171
|
+
if (!ex) continue; // novel → not a conflict
|
|
172
|
+
if (Array.isArray(ex.value) || Array.isArray(inc.value)) continue; // list fields union
|
|
173
|
+
if (valKey(ex.value) === valKey(inc.value)) continue; // agree → corroboration
|
|
174
|
+
const r = resolveDimension([ex, inc]);
|
|
175
|
+
const dim = { field: inc.field, domain: typeof inc.domain === 'string' ? inc.domain : undefined, candidates: r.provenance };
|
|
176
|
+
if (r.status === 'unresolved') { open.push(dim); continue; }
|
|
177
|
+
settled.push(dim);
|
|
178
|
+
// OVERRIDE detection: inferred behavior crossed the §4.7 floor AND there was a
|
|
179
|
+
// contradicting self_reported candidate → behavior just silently overrode what the
|
|
180
|
+
// subject said in the interview. Stage it so the subject SEES it (the existing
|
|
181
|
+
// "settled" path only closes already-open records; without this an override produces
|
|
182
|
+
// zero visibility — the subject's stated preference vanishes silently). Read by
|
|
183
|
+
// upsertConflictRecords to create a resolved record with resolution.by:'system'.
|
|
184
|
+
const selfReport = (r.provenance || []).find(c => c && c.evidence_basis === 'self_reported');
|
|
185
|
+
const winnerNotSelfReport = r.evidence_basis !== 'self_reported';
|
|
186
|
+
if (selfReport && winnerNotSelfReport && valKey(r.value) !== valKey(selfReport.value)) {
|
|
187
|
+
overrides.push({ ...dim, chosen_value: r.value, prior_value: selfReport.value });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return { open, settled, overrides };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Fold a layer's detected conflicts into the fingerprint-level records list. Open a
|
|
194
|
+
// new record (or refresh an existing open one); close an open record whose dimension
|
|
195
|
+
// now settles. A subject's `dismissed` is sticky — never auto-reopened or closed.
|
|
196
|
+
function upsertConflictRecords(records, layerId, detected, now) {
|
|
197
|
+
const list = (Array.isArray(records) ? records : []).map((r) => ({ ...r }));
|
|
198
|
+
const byKey = new Map(list.map((r) => [conflictKey(r.layer, r.field, r.domain), r]));
|
|
199
|
+
for (const d of detected.open) {
|
|
200
|
+
const k = conflictKey(layerId, d.field, d.domain);
|
|
201
|
+
const ex = byKey.get(k);
|
|
202
|
+
if (ex && ex.status === 'dismissed') continue;
|
|
203
|
+
if (ex && ex.status === 'open') { ex.last_seen = now; ex.candidates = d.candidates; continue; }
|
|
204
|
+
const rec = { conflict_id: uuid(), layer: layerId, field: d.field, status: 'open', detected_at: now, last_seen: now, candidates: d.candidates };
|
|
205
|
+
if (d.domain) rec.domain = d.domain;
|
|
206
|
+
byKey.set(k, rec);
|
|
207
|
+
}
|
|
208
|
+
for (const d of detected.settled) {
|
|
209
|
+
const ex = byKey.get(conflictKey(layerId, d.field, d.domain));
|
|
210
|
+
if (ex && ex.status === 'open') { ex.status = 'resolved'; ex.resolved_at = now; }
|
|
211
|
+
}
|
|
212
|
+
// Overrides: stage a `resolved` record marked as a system-auto-resolution against a
|
|
213
|
+
// self-report, so the subject can see + acknowledge or revert. Dismissed records
|
|
214
|
+
// stay sticky; an existing record gets refreshed last_seen + candidates instead of
|
|
215
|
+
// duplicating. by:'system' is the cue conflict-review uses to surface for review.
|
|
216
|
+
for (const d of (detected.overrides || [])) {
|
|
217
|
+
const k = conflictKey(layerId, d.field, d.domain);
|
|
218
|
+
const ex = byKey.get(k);
|
|
219
|
+
if (ex && ex.status === 'dismissed') continue;
|
|
220
|
+
if (ex) {
|
|
221
|
+
ex.last_seen = now; ex.candidates = d.candidates;
|
|
222
|
+
// Don't re-stamp an override the subject already acknowledged (by:'user').
|
|
223
|
+
if (!ex.resolution || ex.resolution.by !== 'user') {
|
|
224
|
+
ex.status = 'resolved'; ex.resolved_at = now;
|
|
225
|
+
ex.resolution = { chosen_value: d.chosen_value, prior_value: d.prior_value, by: 'system' };
|
|
226
|
+
}
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const rec = {
|
|
230
|
+
conflict_id: uuid(), layer: layerId, field: d.field,
|
|
231
|
+
status: 'resolved', detected_at: now, last_seen: now, resolved_at: now,
|
|
232
|
+
candidates: d.candidates,
|
|
233
|
+
resolution: { chosen_value: d.chosen_value, prior_value: d.prior_value, by: 'system' },
|
|
234
|
+
};
|
|
235
|
+
if (d.domain) rec.domain = d.domain;
|
|
236
|
+
byKey.set(k, rec);
|
|
237
|
+
}
|
|
238
|
+
return Array.from(byKey.values());
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Reconcile a full update ({ layerId: layerDoc }) into a fingerprint.
|
|
242
|
+
// Fingerprint shape: { layers: { layerId: layerDoc }, ... }. Returns a new
|
|
243
|
+
// fingerprint object (shallow-cloned down to touched layers). Also stages
|
|
244
|
+
// `conflict_records[]` for genuine self-report↔observed contradictions (above).
|
|
245
|
+
export function reconcileFingerprint(fingerprint, update, opts = {}) {
|
|
246
|
+
const now = opts.now || new Date().toISOString();
|
|
247
|
+
const fp = { ...(fingerprint || {}) };
|
|
248
|
+
fp.layers = { ...(fp.layers || {}) };
|
|
249
|
+
let conflicts = Array.isArray(fp.conflict_records) ? fp.conflict_records : null;
|
|
250
|
+
for (const [layerId, incomingLayer] of Object.entries(update || {})) {
|
|
251
|
+
const prev = fp.layers[layerId];
|
|
252
|
+
const detected = detectConflicts(prev && prev.signals, incomingLayer.signals);
|
|
253
|
+
if (detected.open.length || detected.settled.length || (detected.overrides && detected.overrides.length) || (conflicts && conflicts.length)) {
|
|
254
|
+
conflicts = upsertConflictRecords(conflicts || [], layerId, detected, now);
|
|
255
|
+
}
|
|
256
|
+
// Build the summary AFTER the conflict set is finalized, withholding from the
|
|
257
|
+
// projection (§4.7): (a) any dimension with an OPEN conflict, and (b) any dimension an
|
|
258
|
+
// AUTHORED bound rule governs — the authored value is canonical for the projection, so
|
|
259
|
+
// the inferred summary must not restate/contradict it (resolveEffective decides; the
|
|
260
|
+
// rule is injected separately via renderOperatingRules). authoredGovernedKeys runs over
|
|
261
|
+
// both the existing and incoming signals so every field that could render is covered.
|
|
262
|
+
const rules = Array.isArray(fp.standing_rules) ? fp.standing_rules : [];
|
|
263
|
+
const suppress = new Set([
|
|
264
|
+
...openConflictFieldKeys(conflicts, layerId),
|
|
265
|
+
...authoredGovernedKeys([...((prev && prev.signals) || []), ...(incomingLayer.signals || [])], rules, opts.domain || null),
|
|
266
|
+
]);
|
|
267
|
+
fp.layers[layerId] = reconcileLayer(prev, incomingLayer, now, suppress, opts.sourceId ? { sourceId: opts.sourceId, context: opts.context } : null);
|
|
268
|
+
}
|
|
269
|
+
if (conflicts && conflicts.length) fp.conflict_records = conflicts;
|
|
270
|
+
return fp;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Re-render every layer's `summary` from its CURRENT signals, with the same suppression
|
|
274
|
+
// reconcileFingerprint applies (open conflicts + dimensions an authored rule governs).
|
|
275
|
+
//
|
|
276
|
+
// A cross-browser sync merges signals without running a pass (signal-ledger.js mergeLayers), and
|
|
277
|
+
// the injected context reads `summary`, not signals — so without this, evidence that just arrived
|
|
278
|
+
// from another browser would sit in storage and never reach a prompt until this browser's next
|
|
279
|
+
// extraction pass. Returns the same object when no summary changes.
|
|
280
|
+
export function rerenderSummaries(fingerprint, opts = {}) {
|
|
281
|
+
if (!fingerprint || !fingerprint.layers) return fingerprint;
|
|
282
|
+
const rules = Array.isArray(fingerprint.standing_rules) ? fingerprint.standing_rules : [];
|
|
283
|
+
let out = null;
|
|
284
|
+
for (const [layerId, layer] of Object.entries(fingerprint.layers)) {
|
|
285
|
+
if (!layer || !Array.isArray(layer.signals) || !layer.signals.length) continue;
|
|
286
|
+
const suppress = new Set([
|
|
287
|
+
...openConflictFieldKeys(fingerprint.conflict_records, layerId),
|
|
288
|
+
...authoredGovernedKeys(layer.signals, rules, opts.domain || null),
|
|
289
|
+
]);
|
|
290
|
+
const summary = renderLayerSummary(effectiveSignals(layer.signals).filter(s => s.source !== 'questionnaire'), suppress);
|
|
291
|
+
if (summary === layer.summary) continue;
|
|
292
|
+
out = out || { ...fingerprint, layers: { ...fingerprint.layers } };
|
|
293
|
+
out.layers[layerId] = { ...layer, summary };
|
|
294
|
+
}
|
|
295
|
+
return out || fingerprint;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// The (field / field::domain) keys of the OPEN conflicts on a layer — the dimensions
|
|
299
|
+
// the projection withholds (renderLayerSummary skips them).
|
|
300
|
+
function openConflictFieldKeys(records, layerId) {
|
|
301
|
+
const out = new Set();
|
|
302
|
+
for (const r of (Array.isArray(records) ? records : [])) {
|
|
303
|
+
if (r && r.layer === layerId && r.status === 'open') {
|
|
304
|
+
out.add(r.field + (typeof r.domain === 'string' ? `::${r.domain}` : ''));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export { combineConfidence, sigKey };
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// §4.7 Conflict Resolution (Proposal 0006, Route A) — pure, deterministic, no I/O.
|
|
2
|
+
//
|
|
3
|
+
// Single source of truth for resolving multiple signals on ONE dimension (field)
|
|
4
|
+
// into a single value + provenance. Evidence-tier precedence (corroborated >
|
|
5
|
+
// observed > self_reported), observation-count then recency tiebreak, with a
|
|
6
|
+
// minimum-evidence FLOOR: thin observed evidence does NOT confidently override a
|
|
7
|
+
// self-reported (interview) value — below the floor a genuine conflict resolves to
|
|
8
|
+
// status:"unresolved", which consumers treat conservatively (assert nothing / an
|
|
9
|
+
// agent escalates rather than proceeds).
|
|
10
|
+
//
|
|
11
|
+
// Lives here (browser-safe ESM, packaged with the extension's extraction engine so
|
|
12
|
+
// the producer can render FROM it) and is re-exported by spec/validate-fingerprint.mjs
|
|
13
|
+
// so the spec test-vectors and mcp/health use the exact same resolver. Pure: any
|
|
14
|
+
// conformant implementation computes the same result from the same evidence log, so
|
|
15
|
+
// the fingerprint stays the raw signal log and provenance is derived (no wire change).
|
|
16
|
+
|
|
17
|
+
const isObj = (x) => typeof x === 'object' && x !== null && !Array.isArray(x);
|
|
18
|
+
|
|
19
|
+
export const RESOLUTION_FLOOR = 3; // observed/corroborated needs ≥ this to override self_reported
|
|
20
|
+
const EV_TIER = { corroborated: 3, observed: 2, self_reported: 1 };
|
|
21
|
+
const tierOf = (s) => EV_TIER[s && s.evidence_basis] || 1; // unknown basis → weakest
|
|
22
|
+
const obsOf = (s) => (typeof s.observation_count === 'number' && s.observation_count > 0) ? s.observation_count : 1;
|
|
23
|
+
const isoOf = (s) => typeof s.last_observed === 'string' ? s.last_observed : '';
|
|
24
|
+
export const valKey = (v) => JSON.stringify(v ?? null);
|
|
25
|
+
|
|
26
|
+
// Strict total order: tier, then observation_count, then recency, then value
|
|
27
|
+
// string — fully deterministic, no ties.
|
|
28
|
+
function moreAuthoritative(a, b) {
|
|
29
|
+
if (tierOf(a) !== tierOf(b)) return tierOf(a) - tierOf(b);
|
|
30
|
+
if (obsOf(a) !== obsOf(b)) return obsOf(a) - obsOf(b);
|
|
31
|
+
if (isoOf(a) !== isoOf(b)) return isoOf(a) < isoOf(b) ? -1 : 1;
|
|
32
|
+
const va = valKey(a.value), vb = valKey(b.value);
|
|
33
|
+
return va < vb ? -1 : va > vb ? 1 : 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function resolveDimension(signals) {
|
|
37
|
+
const cand = (signals || []).filter(isObj);
|
|
38
|
+
if (!cand.length) return null;
|
|
39
|
+
const winner = cand.reduce((best, s) => moreAuthoritative(s, best) > 0 ? s : best);
|
|
40
|
+
const distinct = new Set(cand.map((s) => valKey(s.value)));
|
|
41
|
+
let status = 'resolved';
|
|
42
|
+
if (distinct.size > 1) { // a genuine value conflict
|
|
43
|
+
const inferredWinner = tierOf(winner) >= EV_TIER.observed;
|
|
44
|
+
const contradictingSelfReport = cand.some(
|
|
45
|
+
(s) => tierOf(s) === EV_TIER.self_reported && valKey(s.value) !== valKey(winner.value));
|
|
46
|
+
if (inferredWinner && contradictingSelfReport && obsOf(winner) < RESOLUTION_FLOOR) status = 'unresolved';
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
field: winner.field,
|
|
50
|
+
value: winner.value,
|
|
51
|
+
evidence_basis: winner.evidence_basis,
|
|
52
|
+
confidence: winner.confidence,
|
|
53
|
+
status, // "resolved" | "unresolved"
|
|
54
|
+
provenance: cand.map((s) => ({
|
|
55
|
+
value: s.value, source: s.source, evidence_basis: s.evidence_basis,
|
|
56
|
+
confidence: s.confidence, observation_count: s.observation_count, last_observed: s.last_observed,
|
|
57
|
+
})),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Resolve a whole layer's signals → { field: resolution }. Groups by field, then
|
|
62
|
+
// resolveDimension per field. (Polarity precedence per §4.3 is applied upstream by
|
|
63
|
+
// the producer's effectiveSignals(); this resolves VALUE conflicts.)
|
|
64
|
+
export function resolveLayer(signals) {
|
|
65
|
+
const byField = new Map();
|
|
66
|
+
for (const s of (signals || [])) {
|
|
67
|
+
if (!isObj(s) || s.field == null) continue;
|
|
68
|
+
if (!byField.has(s.field)) byField.set(s.field, []);
|
|
69
|
+
byField.get(s.field).push(s);
|
|
70
|
+
}
|
|
71
|
+
const out = {};
|
|
72
|
+
for (const [field, sigs] of byField) out[field] = resolveDimension(sigs);
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── §4.7 effective resolution in context (v0.1.43, resolves OQ7) ──────────────
|
|
77
|
+
//
|
|
78
|
+
// Composes AUTHORED configuration (§5.2 decision-bound standing instructions)
|
|
79
|
+
// with the INFERRED evidence log above, per dimension, in an optional domain
|
|
80
|
+
// context — the exact rules for "a standing instruction that contradicts a
|
|
81
|
+
// high-confidence fingerprint signal in only one domain". The specificity
|
|
82
|
+
// ladder (a lower tier is consulted only when every higher tier is empty):
|
|
83
|
+
//
|
|
84
|
+
// 1. authored_domain — a decision-bound instruction whose decision.domain
|
|
85
|
+
// matches the context domain (resolver substring namespace: the context
|
|
86
|
+
// domain string CONTAINS the rule's domain, case-insensitive). A
|
|
87
|
+
// domain-bound rule NEVER governs outside its domain: with a
|
|
88
|
+
// non-matching or unknown context it is skipped entirely, so the
|
|
89
|
+
// inferred signal keeps governing everywhere else — that is the OQ7
|
|
90
|
+
// one-domain-override semantics.
|
|
91
|
+
// 2. authored_global — a decision-bound instruction with no domain binding.
|
|
92
|
+
// Authored beats inferred regardless of the signal's confidence or
|
|
93
|
+
// evidence tier (§4.7 authored-canonical, unchanged — now stated at
|
|
94
|
+
// dimension granularity).
|
|
95
|
+
// 3. inferred_domain — §4.2.7 domain-tagged signals matching the context
|
|
96
|
+
// domain, resolved among themselves by resolveDimension. An
|
|
97
|
+
// "unresolved" here does NOT fall through to tier 4: a contested
|
|
98
|
+
// domain-specific dimension stays contested (assert nothing).
|
|
99
|
+
// 4. inferred_global — untagged signals, resolved by resolveDimension.
|
|
100
|
+
// Signals tagged with a DIFFERENT domain are excluded when a context
|
|
101
|
+
// domain is known (they describe another context); with no context
|
|
102
|
+
// domain, all signals resolve together (pre-v0.1.43 behavior).
|
|
103
|
+
//
|
|
104
|
+
// Within an authored tier, ties break §5.2-style: lowest priority number,
|
|
105
|
+
// then created_at, then instruction_id — fully deterministic.
|
|
106
|
+
//
|
|
107
|
+
// Pure; same conformance surface as resolveDimension (TC-RESOLVE vectors).
|
|
108
|
+
const prioOf = (r) => (typeof r.priority === 'number' && Number.isFinite(r.priority)) ? r.priority : Infinity;
|
|
109
|
+
const cmpStr = (a, b) => { const x = String(a ?? ''), y = String(b ?? ''); return x < y ? -1 : x > y ? 1 : 0; };
|
|
110
|
+
|
|
111
|
+
export function resolveEffective({ field, signals = [], rules = [], domain = null } = {}) {
|
|
112
|
+
if (field == null) return null;
|
|
113
|
+
const d = (typeof domain === 'string' && domain) ? domain.toLowerCase() : null;
|
|
114
|
+
|
|
115
|
+
const bound = (rules || []).filter((r) => isObj(r) && r.kind === 'instruction'
|
|
116
|
+
&& isObj(r.decision) && r.decision.dimension === field);
|
|
117
|
+
const ruleDomain = (r) => (typeof r.decision.domain === 'string' && r.decision.domain)
|
|
118
|
+
? r.decision.domain.toLowerCase() : null;
|
|
119
|
+
const tier1 = d ? bound.filter((r) => { const rd = ruleDomain(r); return rd && d.includes(rd); }) : [];
|
|
120
|
+
const tier2 = bound.filter((r) => !ruleDomain(r));
|
|
121
|
+
const pickRule = (arr) => arr.slice().sort((a, b) =>
|
|
122
|
+
(prioOf(a) - prioOf(b)) || cmpStr(a.created_at, b.created_at) || cmpStr(a.instruction_id, b.instruction_id))[0];
|
|
123
|
+
|
|
124
|
+
const authoredTier = tier1.length ? 'authored_domain' : tier2.length ? 'authored_global' : null;
|
|
125
|
+
if (authoredTier) {
|
|
126
|
+
const rule = pickRule(tier1.length ? tier1 : tier2);
|
|
127
|
+
return {
|
|
128
|
+
field,
|
|
129
|
+
status: 'resolved',
|
|
130
|
+
basis: authoredTier,
|
|
131
|
+
authored: true,
|
|
132
|
+
// A bound rule may carry a precise catalog value (template-sourced) or
|
|
133
|
+
// none — a valueless negative binding is a cautious bias, which §10
|
|
134
|
+
// consumers already map (consult-policy.js). Surface both faithfully.
|
|
135
|
+
value: rule.decision.value ?? null,
|
|
136
|
+
polarity: rule.decision.polarity || 'positive',
|
|
137
|
+
rule_id: rule.instruction_id || null,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const sigs = (signals || []).filter(isObj);
|
|
142
|
+
const sigDomain = (s) => (typeof s.domain === 'string' && s.domain) ? s.domain.toLowerCase() : null;
|
|
143
|
+
const tagged = d ? sigs.filter((s) => { const sd = sigDomain(s); return sd && d.includes(sd); }) : [];
|
|
144
|
+
const pool = tagged.length ? tagged
|
|
145
|
+
: d ? sigs.filter((s) => !sigDomain(s)) // other-domain-tagged excluded in a known context
|
|
146
|
+
: sigs; // no context → all signals (back-compat)
|
|
147
|
+
const resolved = resolveDimension(pool);
|
|
148
|
+
if (!resolved) return null;
|
|
149
|
+
return { ...resolved, basis: tagged.length ? 'inferred_domain' : 'inferred_global', authored: false };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The (field / field::domain) suppress keys for a layer's INFERRED summary that an
|
|
153
|
+
// AUTHORED bound rule already governs. §4.7 makes a bound rule authored-canonical for
|
|
154
|
+
// its dimension in the projection, so the summary must not ALSO assert a (possibly
|
|
155
|
+
// contradictory) inferred line for that dimension — the authored rule is already injected
|
|
156
|
+
// verbatim (context-block.js renderOperatingRules, higher priority than inferred style).
|
|
157
|
+
// This routes the decision through resolveEffective: a field whose resolution is authored
|
|
158
|
+
// (`authored:true`) has all its rendered-signal keys suppressed. `domain` is optional —
|
|
159
|
+
// null at extraction time, where only authored_GLOBAL rules govern; domain-scoped rules
|
|
160
|
+
// defer to injection-time rendering, which carries the session domain. Pure.
|
|
161
|
+
export function authoredGovernedKeys(signals, rules, domain = null) {
|
|
162
|
+
const out = new Set();
|
|
163
|
+
const list = (Array.isArray(signals) ? signals : []).filter(isObj);
|
|
164
|
+
if (!list.length || !Array.isArray(rules) || !rules.length) return out;
|
|
165
|
+
const byField = new Map();
|
|
166
|
+
for (const s of list) {
|
|
167
|
+
if (typeof s.field !== 'string') continue;
|
|
168
|
+
if (!byField.has(s.field)) byField.set(s.field, []);
|
|
169
|
+
byField.get(s.field).push(s);
|
|
170
|
+
}
|
|
171
|
+
for (const [field, sigs] of byField) {
|
|
172
|
+
const r = resolveEffective({ field, signals: sigs, rules, domain });
|
|
173
|
+
if (r && r.authored) {
|
|
174
|
+
for (const s of sigs) out.add(s.field + (typeof s.domain === 'string' ? `::${s.domain}` : ''));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Proposal 0007 §E — delta promotion (the steady-state path).
|
|
2
|
+
//
|
|
3
|
+
// When the extraction engine's INFERRED Decision & Risk signals show a pattern that
|
|
4
|
+
// has recurred enough to be established (evidence_basis observed/corroborated,
|
|
5
|
+
// observation_count ≥ floor) and the subject has NOT already turned it into an
|
|
6
|
+
// authored rule, propose a `suggested` standing rule into a STAGING queue
|
|
7
|
+
// (`phaedo_fingerprint.suggested_rules`). This is the one rule-shaped object the
|
|
8
|
+
// producer writes — and it is explicitly a *proposal*: never injected, never
|
|
9
|
+
// consulted. Only the subject's endorsement (rule-review.js) turns a suggestion into
|
|
10
|
+
// an authored `standing_rules` entry. So the authored-only invariant on standing_rules
|
|
11
|
+
// holds: the producer stages, the user authors.
|
|
12
|
+
//
|
|
13
|
+
// Pure + deterministic, zero-dep — the house style. The review UI lets the subject
|
|
14
|
+
// edit the proposed text before endorsing, so `proposed_text` only needs to be a
|
|
15
|
+
// reasonable starting sentence.
|
|
16
|
+
|
|
17
|
+
import '../trait-phrases.js'; // UMD side-effect import → globalThis.PhaedoTraitPhrases
|
|
18
|
+
|
|
19
|
+
export const PROMOTION_FLOOR = 3; // §4.7 RESOLUTION_FLOOR — same "established" bar
|
|
20
|
+
const PROMOTABLE_TIERS = new Set(['observed', 'corroborated']); // never promote a self_reported guess
|
|
21
|
+
const DR_LAYER = 'decision_and_risk';
|
|
22
|
+
|
|
23
|
+
const uuid = () =>
|
|
24
|
+
(typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID()
|
|
25
|
+
: 'sug-' + Math.random().toString(36).slice(2, 10);
|
|
26
|
+
|
|
27
|
+
const keyOf = (dim, val) => `${dim}::${JSON.stringify(val ?? null)}`;
|
|
28
|
+
|
|
29
|
+
// (dimension, value) → the candidate sentence, from the shared phrasebook (trait-phrases.js):
|
|
30
|
+
// one written sentence for every trait and value, so a suggestion never reads
|
|
31
|
+
// "I have a consistent way I handle … (0.85)". The subject can still edit before endorsing.
|
|
32
|
+
export function proposeText(dimension, value) {
|
|
33
|
+
return globalThis.PhaedoTraitPhrases.phrase(dimension, value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function endorsedDimensions(fp) {
|
|
37
|
+
const rules = Array.isArray(fp?.standing_rules) ? fp.standing_rules : [];
|
|
38
|
+
return new Set(rules.map((r) => r?.decision?.dimension).filter(Boolean));
|
|
39
|
+
}
|
|
40
|
+
function stagedKeys(fp) {
|
|
41
|
+
const sug = Array.isArray(fp?.suggested_rules) ? fp.suggested_rules : [];
|
|
42
|
+
return new Set(sug.map((s) => keyOf(s?.decision?.dimension, s?.evidence?.value)));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Inspect a fingerprint's Decision & Risk signals and return suggested-rule
|
|
46
|
+
// candidates (does NOT mutate the fingerprint). A candidate is produced for an
|
|
47
|
+
// established, not-yet-authored, not-yet-staged/dismissed (dimension,value).
|
|
48
|
+
export function detectPromotions(fp, { floor = PROMOTION_FLOOR, now = new Date().toISOString() } = {}) {
|
|
49
|
+
const dr = fp?.layers?.[DR_LAYER];
|
|
50
|
+
const signals = Array.isArray(dr?.signals) ? dr.signals : [];
|
|
51
|
+
const covered = endorsedDimensions(fp);
|
|
52
|
+
const seen = stagedKeys(fp); // already suggested OR dismissed → skip
|
|
53
|
+
const local = new Set();
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const s of signals) {
|
|
56
|
+
if (!s || typeof s !== 'object') continue;
|
|
57
|
+
const dim = s.dimension || s.field;
|
|
58
|
+
if (!dim) continue;
|
|
59
|
+
if (!PROMOTABLE_TIERS.has(s.evidence_basis)) continue;
|
|
60
|
+
if ((s.observation_count || 0) < floor) continue;
|
|
61
|
+
if (covered.has(dim)) continue; // already an authored rule on this dimension
|
|
62
|
+
const k = keyOf(dim, s.value);
|
|
63
|
+
if (seen.has(k) || local.has(k)) continue;
|
|
64
|
+
local.add(k);
|
|
65
|
+
out.push({
|
|
66
|
+
suggestion_id: uuid(),
|
|
67
|
+
status: 'suggested',
|
|
68
|
+
source: 'delta_promotion',
|
|
69
|
+
proposed_text: proposeText(dim, s.value),
|
|
70
|
+
decision: { dimension: dim, polarity: s.polarity === 'negative' ? 'negative' : 'positive', ...(s.domain ? { domain: s.domain } : {}) },
|
|
71
|
+
evidence: {
|
|
72
|
+
observation_count: s.observation_count || 0,
|
|
73
|
+
last_observed: s.last_observed || null,
|
|
74
|
+
evidence_basis: s.evidence_basis,
|
|
75
|
+
value: s.value,
|
|
76
|
+
...(s.signal_id ? { signal_id: s.signal_id } : {}),
|
|
77
|
+
},
|
|
78
|
+
created: now,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Merge new suggestions into the staging queue (dedup by dimension+value). Writes
|
|
85
|
+
// ONLY suggested_rules — never standing_rules. Returns a new fingerprint.
|
|
86
|
+
export function stageSuggestions(fp, suggestions) {
|
|
87
|
+
if (!Array.isArray(suggestions) || !suggestions.length) return fp;
|
|
88
|
+
const existing = Array.isArray(fp?.suggested_rules) ? fp.suggested_rules : [];
|
|
89
|
+
const seen = stagedKeys(fp);
|
|
90
|
+
const add = suggestions.filter((s) => {
|
|
91
|
+
const k = keyOf(s?.decision?.dimension, s?.evidence?.value);
|
|
92
|
+
if (seen.has(k)) return false;
|
|
93
|
+
seen.add(k);
|
|
94
|
+
return true;
|
|
95
|
+
});
|
|
96
|
+
if (!add.length) return fp;
|
|
97
|
+
return { ...fp, suggested_rules: [...existing, ...add] };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Producer convenience: detect + stage in one pass.
|
|
101
|
+
export function promoteRules(fp, opts) {
|
|
102
|
+
return stageSuggestions(fp, detectPromotions(fp, opts));
|
|
103
|
+
}
|