@holdyourvoice/hyv 3.6.0 → 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 (92) hide show
  1. package/Readme.md +93 -154
  2. package/dist/ai-editor-rules.js +1 -0
  3. package/dist/ai-editor.js +23 -33
  4. package/dist/analysis.js +33 -0
  5. package/dist/cli/agents.js +88 -0
  6. package/dist/cli/checks.js +226 -0
  7. package/dist/cli/io.js +141 -0
  8. package/dist/cli/lifecycle.js +162 -0
  9. package/dist/cli/profiles.js +246 -0
  10. package/dist/cli/rewriting.js +111 -0
  11. package/dist/cli.js +5 -974
  12. package/dist/copy-spec.js +6 -6
  13. package/dist/editorial-packs.js +4 -3
  14. package/dist/fact-linter.js +45 -27
  15. package/dist/hidden-text.js +6 -6
  16. package/dist/hold-your-voice.mcpb +0 -0
  17. package/dist/internal.js +8 -0
  18. package/dist/judgment-task.js +10 -14
  19. package/dist/learning.js +77 -71
  20. package/dist/lifecycle-adapter.js +2 -5
  21. package/dist/local-eval.js +16 -31
  22. package/dist/mcp-server.js +272 -0
  23. package/dist/mcp-tools.js +10 -19
  24. package/dist/mcp.js +2 -267
  25. package/dist/pipeline.js +18 -105
  26. package/dist/profile-compose.js +18 -11
  27. package/dist/profile-score.js +6 -6
  28. package/dist/profile.js +17 -19
  29. package/dist/provenance-status.js +1 -3
  30. package/dist/rebuild-task.js +18 -52
  31. package/dist/recomposition.js +18 -13
  32. package/dist/rewrite-prompt.js +82 -0
  33. package/dist/rewrite-response.js +27 -0
  34. package/dist/rewrite-task.js +73 -98
  35. package/dist/rule-allowances.js +6 -10
  36. package/dist/semantic-review.js +71 -47
  37. package/dist/stage1-evaluation.js +29 -14
  38. package/dist/strict-quality.js +10 -18
  39. package/dist/version.js +1 -1
  40. package/dist/voice-dna.js +34 -21
  41. package/dist/writing-examples.js +2 -7
  42. package/package.json +6 -3
  43. package/skills/hyv-prepare-judgment/SKILL.md +4 -0
  44. package/dist/agents/catalog.test.js +0 -60
  45. package/dist/agents/emit.test.js +0 -64
  46. package/dist/agents/load.test.js +0 -149
  47. package/dist/ai-editor.test.js +0 -265
  48. package/dist/approval-capability.test.js +0 -52
  49. package/dist/approval-context.test.js +0 -38
  50. package/dist/backtest.test.js +0 -20
  51. package/dist/benchmark.test.js +0 -328
  52. package/dist/canonical-json.test.js +0 -24
  53. package/dist/cli.test.js +0 -731
  54. package/dist/editorial-packs.test.js +0 -94
  55. package/dist/fact-linter.test.js +0 -85
  56. package/dist/hidden-text.test.js +0 -26
  57. package/dist/hygiene.test.js +0 -83
  58. package/dist/judgment-task.test.js +0 -162
  59. package/dist/learning.test.js +0 -325
  60. package/dist/lifecycle-adapter.test.js +0 -56
  61. package/dist/local-eval.test.js +0 -20
  62. package/dist/logic-linter-corpus.test.js +0 -22
  63. package/dist/logic-linter.test.js +0 -39
  64. package/dist/mcp-tools.test.js +0 -286
  65. package/dist/mcp.test.js +0 -312
  66. package/dist/mirror-refs.test.js +0 -63
  67. package/dist/pipeline.test.js +0 -247
  68. package/dist/preservation.test.js +0 -22
  69. package/dist/production-gates.test.js +0 -34
  70. package/dist/profile-compose.test.js +0 -32
  71. package/dist/profile-score.test.js +0 -22
  72. package/dist/profile-watch.test.js +0 -23
  73. package/dist/profile.test.js +0 -141
  74. package/dist/provenance-status.test.js +0 -22
  75. package/dist/rebuild-task.test.js +0 -206
  76. package/dist/recomposition.test.js +0 -34
  77. package/dist/release-audit.test.js +0 -292
  78. package/dist/rewrite-task.test.js +0 -166
  79. package/dist/rule-allowances.test.js +0 -17
  80. package/dist/rule-reconciliation.test.js +0 -50
  81. package/dist/sample-ingest.test.js +0 -52
  82. package/dist/semantic-review.test.js +0 -101
  83. package/dist/stage1-dry-run.test.js +0 -39
  84. package/dist/stage1-evaluation.test.js +0 -184
  85. package/dist/stage1-human-packet.test.js +0 -102
  86. package/dist/stage1-schema-contract.test.js +0 -95
  87. package/dist/stage2-human-packet.test.js +0 -81
  88. package/dist/strict-quality.test.js +0 -62
  89. package/dist/text-provenance.feature.test.js +0 -45
  90. package/dist/text.test.js +0 -16
  91. package/dist/voice-dna.test.js +0 -121
  92. package/dist/writing-examples.test.js +0 -35
package/dist/copy-spec.js CHANGED
@@ -40,12 +40,12 @@ export function verifyClaims(candidate, spec) {
40
40
  const atoms = claim.atoms.map(normalized);
41
41
  const presentAtoms = new Set();
42
42
  for (const sentence of draftSentences) {
43
- if (atoms.some((atom) => atomMatches(sentence.normalizedText, atom))) {
44
- for (const atom of atoms)
45
- if (atomMatches(sentence.normalizedText, atom))
46
- presentAtoms.add(atom);
47
- (sentenceClaims[sentence.index] ??= []).push(claim.id);
48
- }
43
+ const matches = atoms.filter((atom) => atomMatches(sentence.normalizedText, atom));
44
+ if (!matches.length)
45
+ continue;
46
+ for (const atom of matches)
47
+ presentAtoms.add(atom);
48
+ (sentenceClaims[sentence.index] ??= []).push(claim.id);
49
49
  }
50
50
  const missingAtoms = atoms.filter((atom) => !presentAtoms.has(atom));
51
51
  if (!claim.mutable && missingAtoms.length) {
@@ -23,9 +23,8 @@ function isDenied(text) {
23
23
  function isFollowUpDenial(text) {
24
24
  return /^(?:that|this) (?:statement|claim|fact|assertion|point) (?:is|was) (?:not|false|untrue|incorrect)\b/i.test(text.trim());
25
25
  }
26
- function hasAffirmedSourceText(source, text) {
26
+ function hasAffirmedSourceText(sourceSentences, text) {
27
27
  const expected = text.toLowerCase();
28
- const sourceSentences = sentences(source);
29
28
  return sourceSentences.some((sentence, index) => sentence.text.toLowerCase().includes(expected) && !isDenied(sentence.text) && !isFollowUpDenial(sourceSentences[index + 1]?.text ?? ''));
30
29
  }
31
30
  function requiredFactsAreSourced(brief) {
@@ -33,7 +32,9 @@ function requiredFactsAreSourced(brief) {
33
32
  return true;
34
33
  if (!brief.factSources?.length)
35
34
  return false;
36
- return brief.requiredFacts.every((fact) => brief.factSources?.some((source) => hasAffirmedSourceText(source.text, fact.text) || (fact.atoms?.length && fact.atoms.every((atom) => hasAffirmedSourceText(source.text, atom)))));
35
+ const sources = brief.factSources.map((source) => sentences(source.text));
36
+ return brief.requiredFacts.every((fact) => sources.some((source) => hasAffirmedSourceText(source, fact.text)
37
+ || (fact.atoms?.length && fact.atoms.every((atom) => hasAffirmedSourceText(source, atom)))));
37
38
  }
38
39
  function isArgumentMap(value) {
39
40
  if (!value || typeof value !== 'object' || Array.isArray(value))
@@ -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) {
@@ -170,37 +182,46 @@ function receipt(profile, event, status) {
170
182
  const publicMetadata = { mutationId: event.mutationId, eventId: event.eventId, profileId: identity(profile), profileRevision: revision(profile), status, timestamp: event.timestamp };
171
183
  return { version: '1', ...publicMetadata, digest: digest(publicMetadata) };
172
184
  }
173
- function eventBase(profile, kind, options) {
185
+ function requestDigest(event) {
186
+ return digest({ ...event, eventId: undefined, timestamp: undefined, requestDigest: undefined });
187
+ }
188
+ function eventBase(profile, kind, options, details = {}) {
174
189
  const mutationId = options.mutationId ?? randomUUID();
175
190
  const base = {
176
191
  version: '2', eventId: digest({ profile: identity(profile), mutationId }), mutationId, timestamp: new Date().toISOString(),
177
192
  profileRevision: revision(profile), revisionDigest: revisionDigest(profile), authority: options.authority ?? 'team',
178
193
  provenance: options.provenance ?? 'local', weight: options.weight ?? 1, compatibility: options.compatibility ?? 'same-or-newer', kind,
179
194
  };
180
- return { ...base, requestDigest: digest({ ...base, eventId: undefined, timestamp: undefined, requestDigest: undefined }) };
195
+ const event = { ...base, requestDigest: '', ...details };
196
+ event.requestDigest = requestDigest(event);
197
+ return event;
181
198
  }
182
- function mutate(profile, event, options, validate) {
199
+ function withMutation(profile, event, options, operation, source) {
183
200
  try {
184
- const directory = learningDirectory(options);
185
- mkdirSync(directory, { recursive: true, mode: 0o700 });
201
+ mkdirSync(learningDirectory(options), { recursive: true, mode: 0o700 });
186
202
  const file = eventFile(profile, options);
187
- const result = withLock(file, () => {
188
- const events = readEventsFromFile(file);
189
- const existing = events.find((item) => item.mutationId === event.mutationId);
190
- if (existing)
191
- return receipt(profile, existing, existing.requestDigest === event.requestDigest ? 'already_recorded' : 'conflict');
192
- const invalid = validate?.(events);
193
- if (invalid)
194
- return receipt(profile, event, invalid);
195
- writeEventsAtomically(file, [...events, event]);
196
- return receipt(profile, event, 'recorded');
197
- });
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);
198
206
  return result ?? receipt(profile, event, 'lock_timeout');
199
207
  }
200
208
  catch (error) {
201
209
  return receipt(profile, event, error.message.includes('corrupt') ? 'corrupt' : 'write_failed');
202
210
  }
203
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
+ }
204
225
  function countFindings(findings) {
205
226
  const counted = new Map();
206
227
  for (const finding of findings) {
@@ -224,8 +245,7 @@ export function recordVerifiedCandidate(profile, verification, candidate, option
224
245
  const outcome = createHash('sha256').update(`${identity(profile)}\0${candidate}`).digest('hex');
225
246
  if (readEvents(profile, options).some((event) => event.kind === 'verified_candidate' && event.outcome === outcome))
226
247
  return 'nothing_to_learn';
227
- const event = { ...eventBase(profile, 'verified_candidate', { ...options, mutationId: options.mutationId ?? outcome }), resolved: resolved.slice(0, MAX_RESOLVED_FINDINGS), outcome };
228
- event.requestDigest = digest({ ...event, eventId: undefined, timestamp: undefined, requestDigest: undefined });
248
+ const event = eventBase(profile, 'verified_candidate', { ...options, mutationId: options.mutationId ?? outcome }, { resolved: resolved.slice(0, MAX_RESOLVED_FINDINGS), outcome });
229
249
  const result = mutate(profile, event, options);
230
250
  return result.status === 'recorded' ? 'recorded' : result.status === 'already_recorded' ? 'nothing_to_learn' : 'write_failed';
231
251
  }
@@ -235,76 +255,62 @@ export function recordLearningInstruction(profile, instruction, options = {}) {
235
255
  throw new Error('Learning instructions cannot be empty.');
236
256
  if (normalized.length > MAX_INSTRUCTION_CHARACTERS)
237
257
  throw new Error(`Learning instructions must be ${MAX_INSTRUCTION_CHARACTERS} characters or fewer.`);
238
- const event = { ...eventBase(profile, 'instruction', options), instruction: normalized };
239
- event.requestDigest = digest({ ...event, eventId: undefined, timestamp: undefined, requestDigest: undefined });
258
+ const event = eventBase(profile, 'instruction', options, { instruction: normalized });
240
259
  return mutate(profile, event, options);
241
260
  }
242
261
  export function addLearningInstruction(profile, instruction, options = {}) { return recordLearningInstruction(profile, instruction, options).status === 'recorded'; }
262
+ function recordControlEvent(profile, kind, targetEventId, options) {
263
+ const event = eventBase(profile, kind, options, { targetEventId });
264
+ return mutate(profile, event, options, (events) => {
265
+ const target = events.find((item) => item.eventId === targetEventId);
266
+ return !target ? 'not_found' : authorityRank[event.authority] < authorityRank[target.authority] ? 'unauthorized' : undefined;
267
+ });
268
+ }
243
269
  export function ratifyLearningEvent(profile, targetEventId, options = {}) {
244
- const event = { ...eventBase(profile, 'ratification', options), targetEventId };
245
- event.requestDigest = digest({ ...event, eventId: undefined, timestamp: undefined, requestDigest: undefined });
246
- return mutate(profile, event, options, (events) => { const target = events.find((item) => item.eventId === targetEventId); return !target ? 'not_found' : authorityRank[event.authority] < authorityRank[target.authority] ? 'unauthorized' : undefined; });
270
+ return recordControlEvent(profile, 'ratification', targetEventId, options);
247
271
  }
248
272
  export function supersedeLearningEvent(profile, targetEventId, options = {}) {
249
- const event = { ...eventBase(profile, 'supersession', options), targetEventId };
250
- event.requestDigest = digest({ ...event, eventId: undefined, timestamp: undefined, requestDigest: undefined });
251
- return mutate(profile, event, options, (events) => { const target = events.find((item) => item.eventId === targetEventId); return !target ? 'not_found' : authorityRank[event.authority] < authorityRank[target.authority] ? 'unauthorized' : undefined; });
273
+ return recordControlEvent(profile, 'supersession', targetEventId, options);
252
274
  }
253
275
  export function migrateLearningV2ToV3(source, target, options = {}) {
254
276
  const sourceFingerprint = profileFingerprint(source);
255
277
  const migrationId = options.mutationId ?? `migration:${digest({ source: sourceFingerprint, target: target.id, revision: target.revision })}`;
256
278
  const migrationOptions = { ...options, mutationId: migrationId };
257
279
  const marker = { ...eventBase(target, 'migration', migrationOptions), sourceProfile: sourceFingerprint };
258
- try {
259
- const directory = learningDirectory(options);
260
- mkdirSync(directory, { recursive: true, mode: 0o700 });
261
- const file = eventFile(target, options);
262
- const sourceFile = eventFile(source, options);
263
- const result = withLocks([sourceFile, file], () => {
264
- const targetEvents = readEventsFromFile(file);
265
- const existing = targetEvents.find((event) => event.mutationId === marker.mutationId);
266
- if (existing)
267
- return receipt(target, existing, 'already_recorded');
268
- const migrated = readEventsFromFile(sourceFile).filter((event) => event.kind === 'instruction' || event.kind === 'verified_candidate').map((event) => ({
269
- ...event, eventId: digest({ target: target.id, source: event.eventId }), mutationId: `migration:${marker.mutationId}:${event.mutationId}`,
270
- profileRevision: target.revision, revisionDigest: target.revisionDigest, provenance: `migration:${sourceFingerprint}`,
271
- }));
272
- const intended = [...targetEvents, ...migrated, marker];
273
- const compacted = compact(intended);
274
- if (migrated.some((event) => !compacted.some((item) => item.eventId === event.eventId)) || !compacted.some((item) => item.eventId === marker.eventId))
275
- return receipt(target, marker, 'capacity_exceeded');
276
- writeEventsAtomically(file, intended);
277
- return receipt(target, marker, 'recorded');
278
- });
279
- return result ?? receipt(target, marker, 'lock_timeout');
280
- }
281
- catch (error) {
282
- return receipt(target, marker, error.message.includes('corrupt') ? 'corrupt' : 'write_failed');
283
- }
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);
284
296
  }
285
297
  export function inspectLearning(profile, options = {}) {
286
298
  const events = readEvents(profile, options);
287
- const { superseded, ratified } = learningState(events, profile);
299
+ const statusOf = learningState(events, profile);
288
300
  return events.slice(-MAX_EVENTS).map((event) => {
289
- const control = event.kind === 'ratification' || event.kind === 'supersession' || event.kind === 'migration';
290
- const status = control ? 'control' : superseded.has(event.eventId) ? 'superseded' : isCompatible(event, profile) || ratified.has(event.eventId) ? 'active' : 'incompatible';
291
301
  return {
292
302
  version: '1', eventId: event.eventId, mutationId: event.mutationId, eventType: event.kind, timestamp: event.timestamp,
293
303
  profileRevision: event.profileRevision, authority: event.authority, weight: event.weight,
294
- compatibility: event.compatibility, status, ...(event.targetEventId ? { targetEventId: event.targetEventId } : {}),
304
+ compatibility: event.compatibility, status: statusOf(event), ...(event.targetEventId ? { targetEventId: event.targetEventId } : {}),
295
305
  };
296
306
  });
297
307
  }
298
308
  export function composeLearning(profile, options = {}) {
299
309
  const events = readEvents(profile, options);
300
- const { superseded, ratified } = learningState(events, profile);
310
+ const statusOf = learningState(events, profile);
301
311
  const preferences = new Map();
302
312
  for (const event of events) {
303
- if (event.kind !== 'instruction' && event.kind !== 'verified_candidate')
304
- continue;
305
- if (superseded.has(event.eventId))
306
- continue;
307
- if (!isCompatible(event, profile) && !ratified.has(event.eventId))
313
+ if (statusOf(event) !== 'active')
308
314
  continue;
309
315
  const texts = event.kind === 'instruction' && event.instruction ? [{ text: event.instruction, count: event.weight }]
310
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
  }