@holdyourvoice/hyv 3.6.1 → 4.0.0

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.
Files changed (87) hide show
  1. package/Readme.md +91 -96
  2. package/dist/ai-editor-rules.js +1 -0
  3. package/dist/ai-editor.js +19 -13
  4. package/dist/analysis.js +33 -0
  5. package/dist/cli/agents.js +15 -35
  6. package/dist/cli/io.js +41 -37
  7. package/dist/copy-spec.js +6 -6
  8. package/dist/editorial-packs.js +4 -3
  9. package/dist/fact-linter.js +45 -27
  10. package/dist/hidden-text.js +6 -6
  11. package/dist/hold-your-voice.mcpb +0 -0
  12. package/dist/internal.js +8 -0
  13. package/dist/judgment-task.js +10 -14
  14. package/dist/learning.js +66 -67
  15. package/dist/lifecycle-adapter.js +2 -5
  16. package/dist/local-eval.js +16 -31
  17. package/dist/mcp-server.js +272 -0
  18. package/dist/mcp-tools.js +2 -2
  19. package/dist/mcp.js +2 -261
  20. package/dist/pipeline.js +9 -85
  21. package/dist/profile-compose.js +18 -11
  22. package/dist/profile-score.js +6 -6
  23. package/dist/profile.js +17 -19
  24. package/dist/provenance-status.js +1 -3
  25. package/dist/rebuild-task.js +14 -25
  26. package/dist/recomposition.js +18 -13
  27. package/dist/rewrite-prompt.js +82 -0
  28. package/dist/rewrite-task.js +66 -65
  29. package/dist/rule-allowances.js +6 -10
  30. package/dist/semantic-review.js +71 -47
  31. package/dist/stage1-evaluation.js +29 -14
  32. package/dist/strict-quality.js +10 -18
  33. package/dist/version.js +1 -1
  34. package/dist/voice-dna.js +34 -21
  35. package/dist/writing-examples.js +2 -7
  36. package/package.json +6 -3
  37. package/skills/hyv-prepare-judgment/SKILL.md +4 -0
  38. package/dist/agents/catalog.test.js +0 -60
  39. package/dist/agents/emit.test.js +0 -64
  40. package/dist/agents/load.test.js +0 -149
  41. package/dist/ai-editor.test.js +0 -265
  42. package/dist/approval-capability.test.js +0 -52
  43. package/dist/approval-context.test.js +0 -38
  44. package/dist/backtest.test.js +0 -20
  45. package/dist/benchmark.test.js +0 -328
  46. package/dist/canonical-json.test.js +0 -24
  47. package/dist/cli/context.test.js +0 -55
  48. package/dist/cli.test.js +0 -731
  49. package/dist/editorial-packs.test.js +0 -94
  50. package/dist/fact-linter.test.js +0 -85
  51. package/dist/hidden-text.test.js +0 -26
  52. package/dist/hygiene.test.js +0 -83
  53. package/dist/judgment-task.test.js +0 -162
  54. package/dist/learning.test.js +0 -325
  55. package/dist/lifecycle-adapter.test.js +0 -56
  56. package/dist/local-eval.test.js +0 -20
  57. package/dist/logic-linter-corpus.test.js +0 -22
  58. package/dist/logic-linter.test.js +0 -39
  59. package/dist/mcp-tools.test.js +0 -286
  60. package/dist/mcp.test.js +0 -312
  61. package/dist/mirror-refs.test.js +0 -63
  62. package/dist/pipeline.test.js +0 -247
  63. package/dist/preservation.test.js +0 -22
  64. package/dist/production-gates.test.js +0 -34
  65. package/dist/profile-compose.test.js +0 -32
  66. package/dist/profile-score.test.js +0 -22
  67. package/dist/profile-watch.test.js +0 -23
  68. package/dist/profile.test.js +0 -141
  69. package/dist/provenance-status.test.js +0 -22
  70. package/dist/rebuild-task.test.js +0 -206
  71. package/dist/recomposition.test.js +0 -34
  72. package/dist/release-audit.test.js +0 -292
  73. package/dist/rewrite-task.test.js +0 -166
  74. package/dist/rule-allowances.test.js +0 -17
  75. package/dist/rule-reconciliation.test.js +0 -50
  76. package/dist/sample-ingest.test.js +0 -52
  77. package/dist/semantic-review.test.js +0 -101
  78. package/dist/stage1-dry-run.test.js +0 -39
  79. package/dist/stage1-evaluation.test.js +0 -184
  80. package/dist/stage1-human-packet.test.js +0 -102
  81. package/dist/stage1-schema-contract.test.js +0 -95
  82. package/dist/stage2-human-packet.test.js +0 -81
  83. package/dist/strict-quality.test.js +0 -62
  84. package/dist/text-provenance.feature.test.js +0 -45
  85. package/dist/text.test.js +0 -16
  86. package/dist/voice-dna.test.js +0 -121
  87. package/dist/writing-examples.test.js +0 -35
@@ -5,11 +5,13 @@ const QUOTE = /["“]([^"”]+)["”]/;
5
5
  function normal(value) { return value.toLowerCase().replace(/[^\p{L}\p{N}%]+/gu, ' ').trim(); }
6
6
  function tokens(value) { return normal(value).split(' ').filter((word) => word.length > 1 && !STOP_WORDS.has(word)); }
7
7
  function evidence(source, text) { const start = source.text.indexOf(text); return { sourceId: source.id, excerpt: text, start: Math.max(0, start), end: Math.max(0, start) + text.length }; }
8
- function sourceSentences(sources) { return sources.flatMap((source) => sentences(source.text).map((sentence) => ({ source, text: sentence.text }))); }
9
- function hasOverlap(claim, source) {
10
- const claimTokens = tokens(claim);
11
- const sourceTokens = new Set(tokens(source));
12
- return claimTokens.length > 0 && claimTokens.filter((token) => sourceTokens.has(token)).length / claimTokens.length >= 0.8;
8
+ function sourceSentences(sources) {
9
+ return sources.flatMap((source) => sentences(source.text).map((sentence) => ({
10
+ source, text: sentence.text, terms: new Set(tokens(sentence.text)),
11
+ })));
12
+ }
13
+ function hasOverlap(claimTerms, source) {
14
+ return claimTerms.length > 0 && claimTerms.filter((term) => source.terms.has(term)).length / claimTerms.length >= 0.8;
13
15
  }
14
16
  function dates(value) { return [...value.matchAll(DATE)].map((match) => new Date(match[0]).toISOString().slice(0, 10)).filter((value) => value !== ''); }
15
17
  function numbers(value) { return value.match(/\b\d+(?:\.\d+)?\s*(?:%|days?|hours?|weeks?|months?|years?)?\b/gi) ?? []; }
@@ -46,13 +48,11 @@ function kindFor(text) {
46
48
  kinds.push('fact');
47
49
  return kinds;
48
50
  }
49
- function findRelevant(claim, sources) {
50
- const terms = tokens(claim);
51
- return sources.filter(({ text }) => terms.some((term) => tokens(text).includes(term)));
51
+ function evidenceFor(lines) {
52
+ return lines.map(({ source, text }) => evidence(source, text));
52
53
  }
53
54
  function fallbackEvidence(sources) {
54
- const line = sources[0];
55
- return line ? [evidence(line.source, line.text)] : [];
55
+ return evidenceFor(sources.slice(0, 1));
56
56
  }
57
57
  function finding(claim, kind, severity, reason, evidenceItems, confidence, suggestedAction) {
58
58
  return { severity, kind, claim: claim.text, draftLocation: { sentence: claim.sentence, start: claim.start, end: claim.end }, reason, evidence: evidenceItems, confidence, suggestedAction };
@@ -91,14 +91,15 @@ function capabilityFinding(claim, relevant, evidenceItems) {
91
91
  return finding(claim, 'missing_evidence', 'needs_human_review', 'The product capability is not established by the relevant source wording.', evidenceItems, 'medium', 'Confirm the capability with a reviewer or add evidence.');
92
92
  }
93
93
  function findingsForClaim(claim, input, sourceLines, semanticAdapter) {
94
- const relevant = findRelevant(claim.text, sourceLines);
95
- const same = sourceLines.find(({ text }) => hasOverlap(claim.text, text));
96
- const evidenceItems = relevant.length ? relevant.slice(0, 2).map(({ source, text }) => evidence(source, text)) : fallbackEvidence(sourceLines);
94
+ const claimTerms = tokens(claim.text);
95
+ const relevant = sourceLines.filter((source) => claimTerms.some((term) => source.terms.has(term)));
96
+ const same = sourceLines.find((source) => hasOverlap(claimTerms, source));
97
+ const evidenceItems = relevant.length ? evidenceFor(relevant.slice(0, 2)) : fallbackEvidence(sourceLines);
97
98
  const quote = claim.text.match(QUOTE)?.[1];
98
99
  const sourceHasAttribution = input.sources.some((source) => /\b(said|according to|reported)\b/i.test(source.text));
99
100
  if (quote && sourceHasAttribution && !input.sources.some((source) => source.text.includes(quote))) {
100
101
  const quoteRelevant = sourceLines.filter(({ text }) => /\b(said|according to|reported)\b/i.test(text));
101
- const quoteEvidence = quoteRelevant.length ? quoteRelevant.slice(0, 2).map(({ source, text }) => evidence(source, text)) : input.sources.slice(0, 1).map((source) => evidence(source, source.text));
102
+ const quoteEvidence = quoteRelevant.length ? evidenceFor(quoteRelevant.slice(0, 2)) : input.sources.slice(0, 1).map((source) => evidence(source, source.text));
102
103
  return [finding(claim, 'quote_drift', 'error', 'The quoted wording differs from the supplied source.', quoteEvidence, 'high', 'Use the source wording or label the text as a paraphrase.')];
103
104
  }
104
105
  const claimDates = dates(claim.text);
@@ -132,7 +133,7 @@ function findingsForClaim(claim, input, sourceLines, semanticAdapter) {
132
133
  return overreach;
133
134
  if (claimDates.some((date) => sourceDates.includes(date)) || same)
134
135
  return [];
135
- if (!relevant.length && claim.kinds.includes('fact') && tokens(claim.text).length <= 4) {
136
+ if (!relevant.length && claim.kinds.includes('fact') && claimTerms.length <= 4) {
136
137
  return [finding(claim, 'missing_evidence', 'needs_human_review', 'No close source evidence was found; the wording is too sparse for a reliable deterministic verdict.', evidenceItems, 'low', 'Confirm with a reviewer or provide a source.')];
137
138
  }
138
139
  if (!relevant.length || (claim.kinds.includes('number') && !relevant.some(({ text }) => /\b\d+(?:\.\d+)?%?\b/.test(text)))) {
@@ -147,29 +148,46 @@ function findingsForClaim(claim, input, sourceLines, semanticAdapter) {
147
148
  return [finding(claim, 'missing_evidence', 'needs_human_review', 'Relevant source material exists, but deterministic matching could not establish support.', evidenceItems, 'low', 'Review the source context or enable an approved semantic adapter.')];
148
149
  }
149
150
  function draftContradictions(claims, sourceLines) {
150
- const normalized = claims.map((claim) => ({ claim, text: normal(claim.text) }));
151
+ const normalized = claims.map((claim) => {
152
+ const text = normal(claim.text);
153
+ return { claim, core: normal(text.replace(/\bnot\b/g, '')), negated: /\bnot\b/.test(text) };
154
+ });
155
+ const byCore = new Map();
156
+ normalized.forEach(({ core }, index) => {
157
+ const indexes = byCore.get(core) ?? [];
158
+ indexes.push(index);
159
+ byCore.set(core, indexes);
160
+ });
151
161
  const findings = [];
152
162
  for (let index = 0; index < normalized.length; index += 1) {
153
- for (let other = index + 1; other < normalized.length; other += 1) {
154
- const left = normalized[index];
163
+ const left = normalized[index];
164
+ for (const other of byCore.get(left.core)) {
155
165
  const right = normalized[other];
156
- const leftCore = normal(left.text.replace(/\bnot\b/g, ''));
157
- const rightCore = normal(right.text.replace(/\bnot\b/g, ''));
158
- if (leftCore === rightCore && /\bnot\b/.test(left.text) !== /\bnot\b/.test(right.text)) {
166
+ if (other > index && left.negated !== right.negated) {
159
167
  findings.push(finding(right.claim, 'draft_contradiction', 'error', 'This draft claim contradicts an earlier draft claim.', fallbackEvidence(sourceLines), 'high', 'Resolve the two claims before publishing.'));
160
168
  }
161
169
  }
162
170
  }
163
171
  return findings;
164
172
  }
173
+ const CONTRADICTIONS = new Set(['draft_contradiction', 'number_drift', 'date_drift', 'entity_drift', 'quote_drift', 'capability_drift', 'semantic_contradiction']);
165
174
  function summarizeFindings(claims, findings) {
166
- const claimKey = (item) => `${item.draftLocation.start}:${item.draftLocation.end}`;
167
- const unsupported = new Set(findings.filter((item) => item.kind === 'unsupported_claim').map(claimKey)).size;
168
- const contradicted = new Set(findings.filter((item) => ['draft_contradiction', 'number_drift', 'date_drift', 'entity_drift', 'quote_drift', 'capability_drift', 'semantic_contradiction'].includes(item.kind)).map(claimKey)).size;
169
- const humanReview = new Set(findings.filter((item) => item.severity === 'needs_human_review' || item.severity === 'warning').map(claimKey)).size;
175
+ const unsupported = new Set();
176
+ const contradicted = new Set();
177
+ const humanReview = new Set();
178
+ const affected = new Set();
179
+ for (const item of findings) {
180
+ const key = `${item.draftLocation.start}:${item.draftLocation.end}`;
181
+ affected.add(key);
182
+ if (item.kind === 'unsupported_claim')
183
+ unsupported.add(key);
184
+ if (CONTRADICTIONS.has(item.kind))
185
+ contradicted.add(key);
186
+ if (item.severity === 'needs_human_review' || item.severity === 'warning')
187
+ humanReview.add(key);
188
+ }
170
189
  const checked = claims.filter((claim) => !claim.kinds.includes('opinion')).length;
171
- const affected = new Set(findings.map(claimKey)).size;
172
- return { checked, supported: Math.max(0, checked - affected), unsupported, contradicted, humanReview };
190
+ return { checked, supported: Math.max(0, checked - affected.size), unsupported: unsupported.size, contradicted: contradicted.size, humanReview: humanReview.size };
173
191
  }
174
192
  export function lintFacts(input) {
175
193
  if (!input.sources.length)
@@ -48,13 +48,13 @@ export function inspectHiddenText(text, policy = minimalHiddenTextPolicy) {
48
48
  }
49
49
  export function applyHiddenTextPolicy(text, policy = minimalHiddenTextPolicy) {
50
50
  const report = inspectHiddenText(text, policy);
51
- const output = applyOnce(text, policy);
52
- const remaining = inspectHiddenText(output, policy).findings;
53
- const again = applyOnce(output, policy);
54
- return { ...report, outputHash: hash(output), output, remaining, idempotent: again === output };
51
+ const output = removeProposedChanges(text, report.proposedChanges);
52
+ const remainingReport = inspectHiddenText(output, policy);
53
+ const again = removeProposedChanges(output, remainingReport.proposedChanges);
54
+ return { ...report, outputHash: hash(output), output, remaining: remainingReport.findings, idempotent: again === output };
55
55
  }
56
- function applyOnce(text, policy) {
57
- const offsets = new Set(inspectHiddenText(text, policy).proposedChanges.map((item) => item.offset));
56
+ function removeProposedChanges(text, changes) {
57
+ const offsets = new Set(changes.map((item) => item.offset));
58
58
  let output = '';
59
59
  for (let offset = 0; offset < text.length;) {
60
60
  const value = text.codePointAt(offset);
Binary file
package/dist/internal.js CHANGED
@@ -28,3 +28,11 @@ export function isText(value, maximum = 256) {
28
28
  export function escaped(value) {
29
29
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
30
30
  }
31
+ export function verificationMatchesBinding(verification, binding) {
32
+ return verification.artifactFingerprint === binding.deterministicArtifactFingerprint
33
+ && verification.sourceHash === binding.sourceHash
34
+ && verification.candidateHash === binding.candidateHash
35
+ && verification.profileId === binding.profileId
36
+ && verification.profileRevisionDigest === binding.profileRevisionDigest
37
+ && verification.rulesetVersion === binding.rulesetVersion;
38
+ }
@@ -17,9 +17,6 @@ function isRange(value) {
17
17
  && value.startSentenceId >= 1
18
18
  && value.endSentenceId >= value.startSentenceId;
19
19
  }
20
- function rangesContiguous(ranges) {
21
- return ranges.every((range) => range.endSentenceId >= range.startSentenceId);
22
- }
23
20
  export function parseJudgmentEnvelope(value) {
24
21
  if (!value || typeof value !== 'object' || Array.isArray(value))
25
22
  throw new Error('Judgment envelope must be an object.');
@@ -105,12 +102,15 @@ export function fingerprintPreEditReduction(reduction) {
105
102
  function reduced(decision, editScope, reason) {
106
103
  return { decision, editScope, ...(reason ? { reason } : {}), recommendationFingerprint: fingerprintReduction(decision, editScope, reason) };
107
104
  }
108
- export function reducePreEdit(envelopes) {
109
- if (envelopes.length !== PRE_EDIT_KINDS.length)
110
- throw new Error('Pre-edit reduction requires triage, argument, and form envelopes.');
105
+ function requireKinds(envelopes, required, message) {
106
+ if (envelopes.length !== required.length)
107
+ throw new Error(message);
111
108
  const kinds = new Set(envelopes.map((envelope) => envelope.judgmentType));
112
- if (PRE_EDIT_KINDS.some((kind) => !kinds.has(kind)))
113
- throw new Error('Pre-edit reduction requires triage, argument, and form envelopes.');
109
+ if (required.some((kind) => !kinds.has(kind)))
110
+ throw new Error(message);
111
+ }
112
+ export function reducePreEdit(envelopes) {
113
+ requireKinds(envelopes, PRE_EDIT_KINDS, 'Pre-edit reduction requires triage, argument, and form envelopes.');
114
114
  if (envelopes.some((envelope) => envelope.stage !== 'pre-edit'))
115
115
  throw new Error('Pre-edit reduction rejects post-candidate envelopes.');
116
116
  const argument = envelopes.find((envelope) => envelope.judgmentType === 'argument');
@@ -121,7 +121,7 @@ export function reducePreEdit(envelopes) {
121
121
  return reduced('REBUILD', { ranges: [] });
122
122
  }
123
123
  const ranges = envelopes.flatMap((envelope) => envelope.editScope?.ranges ?? namedRanges(envelope.findings));
124
- if (!rangesContiguous(ranges) || ranges.some((range) => range.endSentenceId < range.startSentenceId)) {
124
+ if (!ranges.every((range) => range.endSentenceId >= range.startSentenceId)) {
125
125
  throw new Error('Edit scope must name contiguous sentence ranges.');
126
126
  }
127
127
  if (envelopes.every((envelope) => envelope.decision === 'SHIP') && ranges.length === 0) {
@@ -132,11 +132,7 @@ export function reducePreEdit(envelopes) {
132
132
  return reduced('EDIT', { ranges });
133
133
  }
134
134
  export function reducePostCandidate(envelopes) {
135
- if (envelopes.length !== POST_CANDIDATE_KINDS.length)
136
- throw new Error('Post-candidate reduction requires argument, polarity, form, flatness, and semantic envelopes.');
137
- const kinds = new Set(envelopes.map((envelope) => envelope.judgmentType));
138
- if (POST_CANDIDATE_KINDS.some((kind) => !kinds.has(kind)))
139
- throw new Error('Post-candidate reduction requires argument, polarity, form, flatness, and semantic envelopes.');
135
+ requireKinds(envelopes, POST_CANDIDATE_KINDS, 'Post-candidate reduction requires argument, polarity, form, flatness, and semantic envelopes.');
140
136
  if (envelopes.some((envelope) => envelope.stage !== 'post-candidate'))
141
137
  throw new Error('Post-candidate reduction rejects pre-edit envelopes.');
142
138
  if (envelopes.some((envelope) => envelope.decision === 'REBUILD'))
package/dist/learning.js CHANGED
@@ -18,7 +18,7 @@ function canonicalJson(value) {
18
18
  return JSON.stringify(value);
19
19
  }
20
20
  export function profileFingerprint(profile) {
21
- return createHash('sha256').update(canonicalJson(profile)).digest('hex');
21
+ return digest(profile);
22
22
  }
23
23
  function identity(profile) { return profile.version === '3' ? profile.id : profileFingerprint(profile); }
24
24
  function revision(profile) { return profile.version === '3' ? profile.revision : 1; }
@@ -99,15 +99,27 @@ function readEventsFromFile(file) {
99
99
  function readEvents(profile, options = {}) { return readEventsFromFile(eventFile(profile, options)); }
100
100
  function serialize(events) { return events.length ? `${events.map((event) => JSON.stringify(event)).join('\n')}\n` : ''; }
101
101
  function learningState(events, profile) {
102
- return {
103
- superseded: new Set(events.filter((event) => event.kind === 'supersession').map((event) => event.targetEventId)),
104
- ratified: new Set(events.filter((event) => event.kind === 'ratification' && event.profileRevision <= revision(profile)).map((event) => event.targetEventId)),
102
+ const superseded = new Set();
103
+ const ratified = new Set();
104
+ const currentRevision = revision(profile);
105
+ for (const event of events) {
106
+ if (event.kind === 'supersession')
107
+ superseded.add(event.targetEventId);
108
+ if (event.kind === 'ratification' && event.profileRevision <= currentRevision)
109
+ ratified.add(event.targetEventId);
110
+ }
111
+ return (event) => {
112
+ if (event.kind === 'ratification' || event.kind === 'supersession' || event.kind === 'migration')
113
+ return 'control';
114
+ if (superseded.has(event.eventId))
115
+ return 'superseded';
116
+ if (ratified.has(event.eventId))
117
+ return 'active';
118
+ const compatible = event.profileRevision <= currentRevision
119
+ && (event.compatibility === 'same-or-newer' || event.profileRevision === currentRevision);
120
+ return compatible ? 'active' : 'incompatible';
105
121
  };
106
122
  }
107
- function isCompatible(event, profile) {
108
- return event.profileRevision <= revision(profile)
109
- && (event.compatibility === 'same-or-newer' || event.profileRevision === revision(profile));
110
- }
111
123
  function withLock(file, operation) {
112
124
  const lock = `${file}.lock`;
113
125
  for (let attempt = 0; attempt < 100; attempt += 1) {
@@ -143,7 +155,7 @@ function compact(events) {
143
155
  const selected = new Set(content.slice(0, MAX_EVENTS).map((event) => event.eventId));
144
156
  const controls = events.filter((event) => event.kind === 'migration' || ((event.kind === 'ratification' || event.kind === 'supersession') && selected.has(event.targetEventId ?? '')));
145
157
  while (selected.size + controls.length > MAX_EVENTS)
146
- selected.delete(content[[...selected].length - 1]?.eventId ?? '');
158
+ selected.delete(content[selected.size - 1]?.eventId ?? '');
147
159
  const retained = events.filter((event) => selected.has(event.eventId) || (event.kind === 'migration') || ((event.kind === 'ratification' || event.kind === 'supersession') && selected.has(event.targetEventId ?? '')))
148
160
  .sort((a, b) => a.timestamp.localeCompare(b.timestamp) || a.eventId.localeCompare(b.eventId));
149
161
  while (retained.length && Buffer.byteLength(serialize(retained)) > MAX_STORAGE_BYTES) {
@@ -173,37 +185,43 @@ function receipt(profile, event, status) {
173
185
  function requestDigest(event) {
174
186
  return digest({ ...event, eventId: undefined, timestamp: undefined, requestDigest: undefined });
175
187
  }
176
- function eventBase(profile, kind, options) {
188
+ function eventBase(profile, kind, options, details = {}) {
177
189
  const mutationId = options.mutationId ?? randomUUID();
178
190
  const base = {
179
191
  version: '2', eventId: digest({ profile: identity(profile), mutationId }), mutationId, timestamp: new Date().toISOString(),
180
192
  profileRevision: revision(profile), revisionDigest: revisionDigest(profile), authority: options.authority ?? 'team',
181
193
  provenance: options.provenance ?? 'local', weight: options.weight ?? 1, compatibility: options.compatibility ?? 'same-or-newer', kind,
182
194
  };
183
- return { ...base, requestDigest: requestDigest(base) };
195
+ const event = { ...base, requestDigest: '', ...details };
196
+ event.requestDigest = requestDigest(event);
197
+ return event;
184
198
  }
185
- function mutate(profile, event, options, validate) {
199
+ function withMutation(profile, event, options, operation, source) {
186
200
  try {
187
- const directory = learningDirectory(options);
188
- mkdirSync(directory, { recursive: true, mode: 0o700 });
201
+ mkdirSync(learningDirectory(options), { recursive: true, mode: 0o700 });
189
202
  const file = eventFile(profile, options);
190
- const result = withLock(file, () => {
191
- const events = readEventsFromFile(file);
192
- const existing = events.find((item) => item.mutationId === event.mutationId);
193
- if (existing)
194
- return receipt(profile, existing, existing.requestDigest === event.requestDigest ? 'already_recorded' : 'conflict');
195
- const invalid = validate?.(events);
196
- if (invalid)
197
- return receipt(profile, event, invalid);
198
- writeEventsAtomically(file, [...events, event]);
199
- return receipt(profile, event, 'recorded');
200
- });
203
+ const sourceFile = source ? eventFile(source, options) : undefined;
204
+ const run = () => operation(file, sourceFile);
205
+ const result = sourceFile === undefined ? withLock(file, run) : withLocks([sourceFile, file], run);
201
206
  return result ?? receipt(profile, event, 'lock_timeout');
202
207
  }
203
208
  catch (error) {
204
209
  return receipt(profile, event, error.message.includes('corrupt') ? 'corrupt' : 'write_failed');
205
210
  }
206
211
  }
212
+ function mutate(profile, event, options, validate) {
213
+ return withMutation(profile, event, options, (file) => {
214
+ const events = readEventsFromFile(file);
215
+ const existing = events.find((item) => item.mutationId === event.mutationId);
216
+ if (existing)
217
+ return receipt(profile, existing, existing.requestDigest === event.requestDigest ? 'already_recorded' : 'conflict');
218
+ const invalid = validate?.(events);
219
+ if (invalid)
220
+ return receipt(profile, event, invalid);
221
+ writeEventsAtomically(file, [...events, event]);
222
+ return receipt(profile, event, 'recorded');
223
+ });
224
+ }
207
225
  function countFindings(findings) {
208
226
  const counted = new Map();
209
227
  for (const finding of findings) {
@@ -227,8 +245,7 @@ export function recordVerifiedCandidate(profile, verification, candidate, option
227
245
  const outcome = createHash('sha256').update(`${identity(profile)}\0${candidate}`).digest('hex');
228
246
  if (readEvents(profile, options).some((event) => event.kind === 'verified_candidate' && event.outcome === outcome))
229
247
  return 'nothing_to_learn';
230
- const event = { ...eventBase(profile, 'verified_candidate', { ...options, mutationId: options.mutationId ?? outcome }), resolved: resolved.slice(0, MAX_RESOLVED_FINDINGS), outcome };
231
- event.requestDigest = requestDigest(event);
248
+ const event = eventBase(profile, 'verified_candidate', { ...options, mutationId: options.mutationId ?? outcome }, { resolved: resolved.slice(0, MAX_RESOLVED_FINDINGS), outcome });
232
249
  const result = mutate(profile, event, options);
233
250
  return result.status === 'recorded' ? 'recorded' : result.status === 'already_recorded' ? 'nothing_to_learn' : 'write_failed';
234
251
  }
@@ -238,14 +255,12 @@ export function recordLearningInstruction(profile, instruction, options = {}) {
238
255
  throw new Error('Learning instructions cannot be empty.');
239
256
  if (normalized.length > MAX_INSTRUCTION_CHARACTERS)
240
257
  throw new Error(`Learning instructions must be ${MAX_INSTRUCTION_CHARACTERS} characters or fewer.`);
241
- const event = { ...eventBase(profile, 'instruction', options), instruction: normalized };
242
- event.requestDigest = requestDigest(event);
258
+ const event = eventBase(profile, 'instruction', options, { instruction: normalized });
243
259
  return mutate(profile, event, options);
244
260
  }
245
261
  export function addLearningInstruction(profile, instruction, options = {}) { return recordLearningInstruction(profile, instruction, options).status === 'recorded'; }
246
262
  function recordControlEvent(profile, kind, targetEventId, options) {
247
- const event = { ...eventBase(profile, kind, options), targetEventId };
248
- event.requestDigest = requestDigest(event);
263
+ const event = eventBase(profile, kind, options, { targetEventId });
249
264
  return mutate(profile, event, options, (events) => {
250
265
  const target = events.find((item) => item.eventId === targetEventId);
251
266
  return !target ? 'not_found' : authorityRank[event.authority] < authorityRank[target.authority] ? 'unauthorized' : undefined;
@@ -262,56 +277,40 @@ export function migrateLearningV2ToV3(source, target, options = {}) {
262
277
  const migrationId = options.mutationId ?? `migration:${digest({ source: sourceFingerprint, target: target.id, revision: target.revision })}`;
263
278
  const migrationOptions = { ...options, mutationId: migrationId };
264
279
  const marker = { ...eventBase(target, 'migration', migrationOptions), sourceProfile: sourceFingerprint };
265
- try {
266
- const directory = learningDirectory(options);
267
- mkdirSync(directory, { recursive: true, mode: 0o700 });
268
- const file = eventFile(target, options);
269
- const sourceFile = eventFile(source, options);
270
- const result = withLocks([sourceFile, file], () => {
271
- const targetEvents = readEventsFromFile(file);
272
- const existing = targetEvents.find((event) => event.mutationId === marker.mutationId);
273
- if (existing)
274
- return receipt(target, existing, 'already_recorded');
275
- const migrated = readEventsFromFile(sourceFile).filter((event) => event.kind === 'instruction' || event.kind === 'verified_candidate').map((event) => ({
276
- ...event, eventId: digest({ target: target.id, source: event.eventId }), mutationId: `migration:${marker.mutationId}:${event.mutationId}`,
277
- profileRevision: target.revision, revisionDigest: target.revisionDigest, provenance: `migration:${sourceFingerprint}`,
278
- }));
279
- const intended = [...targetEvents, ...migrated, marker];
280
- const compacted = compact(intended);
281
- if (migrated.some((event) => !compacted.some((item) => item.eventId === event.eventId)) || !compacted.some((item) => item.eventId === marker.eventId))
282
- return receipt(target, marker, 'capacity_exceeded');
283
- writeEventsAtomically(file, intended);
284
- return receipt(target, marker, 'recorded');
285
- });
286
- return result ?? receipt(target, marker, 'lock_timeout');
287
- }
288
- catch (error) {
289
- return receipt(target, marker, error.message.includes('corrupt') ? 'corrupt' : 'write_failed');
290
- }
280
+ return withMutation(target, marker, options, (file, sourceFile) => {
281
+ const targetEvents = readEventsFromFile(file);
282
+ const existing = targetEvents.find((event) => event.mutationId === marker.mutationId);
283
+ if (existing)
284
+ return receipt(target, existing, 'already_recorded');
285
+ const migrated = readEventsFromFile(sourceFile).filter((event) => event.kind === 'instruction' || event.kind === 'verified_candidate').map((event) => ({
286
+ ...event, eventId: digest({ target: target.id, source: event.eventId }), mutationId: `migration:${marker.mutationId}:${event.mutationId}`,
287
+ profileRevision: target.revision, revisionDigest: target.revisionDigest, provenance: `migration:${sourceFingerprint}`,
288
+ }));
289
+ const intended = [...targetEvents, ...migrated, marker];
290
+ const retainedIds = new Set(compact(intended).map((event) => event.eventId));
291
+ if (!retainedIds.has(marker.eventId) || migrated.some((event) => !retainedIds.has(event.eventId)))
292
+ return receipt(target, marker, 'capacity_exceeded');
293
+ writeEventsAtomically(file, intended);
294
+ return receipt(target, marker, 'recorded');
295
+ }, source);
291
296
  }
292
297
  export function inspectLearning(profile, options = {}) {
293
298
  const events = readEvents(profile, options);
294
- const { superseded, ratified } = learningState(events, profile);
299
+ const statusOf = learningState(events, profile);
295
300
  return events.slice(-MAX_EVENTS).map((event) => {
296
- const control = event.kind === 'ratification' || event.kind === 'supersession' || event.kind === 'migration';
297
- const status = control ? 'control' : superseded.has(event.eventId) ? 'superseded' : isCompatible(event, profile) || ratified.has(event.eventId) ? 'active' : 'incompatible';
298
301
  return {
299
302
  version: '1', eventId: event.eventId, mutationId: event.mutationId, eventType: event.kind, timestamp: event.timestamp,
300
303
  profileRevision: event.profileRevision, authority: event.authority, weight: event.weight,
301
- compatibility: event.compatibility, status, ...(event.targetEventId ? { targetEventId: event.targetEventId } : {}),
304
+ compatibility: event.compatibility, status: statusOf(event), ...(event.targetEventId ? { targetEventId: event.targetEventId } : {}),
302
305
  };
303
306
  });
304
307
  }
305
308
  export function composeLearning(profile, options = {}) {
306
309
  const events = readEvents(profile, options);
307
- const { superseded, ratified } = learningState(events, profile);
310
+ const statusOf = learningState(events, profile);
308
311
  const preferences = new Map();
309
312
  for (const event of events) {
310
- if (event.kind !== 'instruction' && event.kind !== 'verified_candidate')
311
- continue;
312
- if (superseded.has(event.eventId))
313
- continue;
314
- if (!isCompatible(event, profile) && !ratified.has(event.eventId))
313
+ if (statusOf(event) !== 'active')
315
314
  continue;
316
315
  const texts = event.kind === 'instruction' && event.instruction ? [{ text: event.instruction, count: event.weight }]
317
316
  : (event.resolved ?? []).map((finding) => ({ text: `Previously verified repair: ${finding.engine}/${finding.id}.`, count: finding.count * event.weight }));
@@ -3,7 +3,7 @@ import { verifyApprovalCapability } from './approval-capability.js';
3
3
  import { createInitialLifecycleArtifact, isValidLifecycleArtifact, parseSemanticVerdict, prepareSemanticReviewTask, reduceRewriteLifecycle } from './semantic-review.js';
4
4
  import { verifyDeterministically } from './pipeline.js';
5
5
  import { recordVerifiedCandidate } from './learning.js';
6
- import { MAX_JSON_BYTES } from './internal.js';
6
+ import { MAX_JSON_BYTES, verificationMatchesBinding } from './internal.js';
7
7
  export function prepareLifecycle(deterministic, binding, receipt, policy, allowedViolations) {
8
8
  const task = prepareSemanticReviewTask(binding, policy, receipt, allowedViolations);
9
9
  return { task, artifact: createInitialLifecycleArtifact(task, deterministic) };
@@ -67,10 +67,7 @@ export function recordApprovedLearning(request) {
67
67
  if (!replay.ok || canonicalJson(replay.artifact) !== canonicalJson(approved) || approved.status !== 'approved')
68
68
  throw new Error('Approved learning is not authorized.');
69
69
  const deterministic = verifyDeterministically(source, candidate, profile, copySpec, writingBrief);
70
- if (!deterministic.verification.passed || deterministic.artifact.artifactFingerprint !== approved.binding.deterministicArtifactFingerprint
71
- || deterministic.artifact.sourceHash !== approved.binding.sourceHash || deterministic.artifact.candidateHash !== approved.binding.candidateHash
72
- || deterministic.artifact.profileId !== approved.binding.profileId || deterministic.artifact.profileRevisionDigest !== approved.binding.profileRevisionDigest
73
- || deterministic.artifact.rulesetVersion !== approved.binding.rulesetVersion)
70
+ if (!deterministic.verification.passed || !verificationMatchesBinding(deterministic.artifact, approved.binding))
74
71
  throw new Error('Approved learning binding does not match deterministic verification.');
75
72
  return recordVerifiedCandidate(profile, deterministic.verification, candidate, { mutationId: `approved:${approved.artifactFingerprint}`, authority: 'team', provenance: `approved:${approved.capabilityFingerprint}`, compatibility: 'exact' });
76
73
  }
@@ -23,42 +23,27 @@ function groupedParagraphs(values, label) {
23
23
  if (values.length < 2 || values.some((value) => !value.paragraphId || !value.text.trim()) || new Set(values.map((value) => value.paragraphId)).size < 2)
24
24
  throw new Error(`${label} needs at least two paragraph IDs with text.`);
25
25
  }
26
- function tfIdf(documents) {
27
- const documentTerms = documents.map((document) => terms(document));
28
- const df = new Map();
29
- for (const document of documentTerms)
30
- for (const term of new Set(document))
31
- df.set(term, (df.get(term) ?? 0) + 1);
32
- const vocabulary = [...df.keys()].sort();
33
- const idf = new Map([...df].map(([term, frequency]) => [term, Math.log((documents.length + 1) / (frequency + 1)) + 1]));
34
- const toVector = (document) => {
35
- const termsInDocument = terms(document);
36
- const count = new Map();
37
- for (const term of termsInDocument)
38
- count.set(term, (count.get(term) ?? 0) + 1);
39
- const vector = {};
40
- for (const [term, occurrences] of count)
41
- if (idf.has(term))
42
- vector[term] = (occurrences / Math.max(1, termsInDocument.length)) * idf.get(term);
43
- return vector;
44
- };
45
- return {
46
- vocabulary,
47
- idf,
48
- vectors: documents.map(toVector),
49
- };
50
- }
51
- function vectorWithIdf(document, idf) {
52
- const documentTerms = terms(document);
53
- const count = new Map();
26
+ function vectorWithIdf(documentTerms, idf) {
27
+ const counts = new Map();
54
28
  for (const term of documentTerms)
55
- count.set(term, (count.get(term) ?? 0) + 1);
29
+ counts.set(term, (counts.get(term) ?? 0) + 1);
56
30
  const vector = {};
57
- for (const [term, occurrences] of count)
31
+ for (const [term, occurrences] of counts) {
58
32
  if (idf.has(term))
59
33
  vector[term] = (occurrences / Math.max(1, documentTerms.length)) * idf.get(term);
34
+ }
60
35
  return vector;
61
36
  }
37
+ function tfIdf(documents) {
38
+ const documentTerms = documents.map(terms);
39
+ const frequencies = new Map();
40
+ for (const document of documentTerms) {
41
+ for (const term of new Set(document))
42
+ frequencies.set(term, (frequencies.get(term) ?? 0) + 1);
43
+ }
44
+ const idf = new Map([...frequencies].map(([term, frequency]) => [term, Math.log((documents.length + 1) / (frequency + 1)) + 1]));
45
+ return { idf, vectors: documentTerms.map((document) => vectorWithIdf(document, idf)) };
46
+ }
62
47
  function sigmoid(value) { return value >= 0 ? 1 / (1 + Math.exp(-value)) : Math.exp(value) / (1 + Math.exp(value)); }
63
48
  /** Deterministic, train-only logistic regression over sparse TF-IDF vectors. */
64
49
  function localAuthorshipProbability(candidate, user, shadow) {
@@ -75,7 +60,7 @@ function localAuthorshipProbability(candidate, user, shadow) {
75
60
  weights[term] = (weights[term] ?? 0) + 0.12 * error * value;
76
61
  }
77
62
  }
78
- const candidateVector = vectorWithIdf(candidate, transformed.idf);
63
+ const candidateVector = vectorWithIdf(terms(candidate), transformed.idf);
79
64
  const score = bias + Object.entries(candidateVector).reduce((sum, [term, value]) => sum + (weights[term] ?? 0) * value, 0);
80
65
  return Number(sigmoid(score).toFixed(3));
81
66
  }