@holdyourvoice/hyv 3.6.1 → 4.0.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.
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 +87 -37
  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 +125 -0
  28. package/dist/rewrite-task.js +117 -70
  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
@@ -1,3 +1,4 @@
1
+ import { deriveFactRepair, isSourceBackedRepair } from './rewrite-prompt.js';
1
2
  import { parseWritingBrief } from './editorial-packs.js';
2
3
  import { finalOutputCheck, hygieneSourceFindings } from './hygiene.js';
3
4
  import { analyze, deriveEditScope, renderRewritePrompt, verifyDeterministically } from './pipeline.js';
@@ -9,47 +10,32 @@ const MAX_REPLACEMENT_CHARACTERS = 10_000;
9
10
  function isFailure(value) {
10
11
  return typeof value === 'object' && value !== null && 'code' in value;
11
12
  }
12
- function parseResponse(value) {
13
- const raw = typeof value === 'string' ? parseResponseJson(value) : value;
14
- if (isFailure(raw))
15
- return raw;
16
- if (!raw || typeof raw !== 'object' || Array.isArray(raw))
17
- return failure('invalid_response_shape', 'Response must be an object.');
18
- const response = raw;
19
- if (typeof response.taskFingerprint !== 'string' || response.taskFingerprint.length !== 64)
20
- return failure('invalid_response_shape', 'Response must include the task fingerprint.', 'taskFingerprint');
21
- if (response.mode === 'REBUILD')
22
- return failure('rebuild_response_on_edit_task', 'Rebuild responses cannot satisfy edit tasks.', 'mode');
23
- if (response.mode === 'SHIP' && response.version === '1') {
24
- return { version: '1', mode: 'SHIP', taskFingerprint: response.taskFingerprint };
25
- }
26
- if (response.version === '2') {
27
- if (!Array.isArray(response.operations))
28
- return failure('invalid_response_shape', 'Version 2 responses require an operations array.', 'operations');
29
- if (response.operations.length > MAX_REPLACEMENTS)
30
- return failure('invalid_response_shape', `Response may include at most ${MAX_REPLACEMENTS} replacements.`, 'operations');
31
- for (const [index, operation] of response.operations.entries()) {
32
- if (!operation || typeof operation !== 'object' || !Number.isInteger(operation.startSentenceId) || !Number.isInteger(operation.endSentenceId) || typeof operation.text !== 'string') {
33
- return failure('invalid_response_shape', 'Every operation requires integer startSentenceId, endSentenceId, and string text.', `operations[${index}]`);
34
- }
35
- if (operation.endSentenceId < operation.startSentenceId)
36
- return failure('noncontiguous_range', 'A range must be inclusive and contiguous.', `operations[${index}]`);
37
- if (operation.text.length > MAX_REPLACEMENT_CHARACTERS)
38
- return failure('invalid_replacement_text', `Replacement text must contain at most ${MAX_REPLACEMENT_CHARACTERS} characters.`, `operations[${index}].text`);
13
+ function parseRangeResponse(response) {
14
+ if (!Array.isArray(response.operations))
15
+ return failure('invalid_response_shape', 'Version 2 responses require an operations array.', 'operations');
16
+ if (response.operations.length > MAX_REPLACEMENTS)
17
+ return failure('invalid_response_shape', `Response may include at most ${MAX_REPLACEMENTS} replacements.`, 'operations');
18
+ for (const [index, operation] of response.operations.entries()) {
19
+ if (!operation || typeof operation !== 'object' || !Number.isInteger(operation.startSentenceId) || !Number.isInteger(operation.endSentenceId) || typeof operation.text !== 'string') {
20
+ return failure('invalid_response_shape', 'Every operation requires integer startSentenceId, endSentenceId, and string text.', `operations[${index}]`);
39
21
  }
40
- if (response.hygieneOperations !== undefined) {
41
- if (!Array.isArray(response.hygieneOperations))
42
- return failure('invalid_response_shape', 'Hygiene operations must be an array.', 'hygieneOperations');
43
- for (const [index, operation] of response.hygieneOperations.entries()) {
44
- if (!operation || !Number.isInteger(operation.start) || !Number.isInteger(operation.end) || typeof operation.text !== 'string' || operation.end < operation.start) {
45
- return failure('invalid_response_shape', 'Every hygiene operation requires integer start, end, and string text.', `hygieneOperations[${index}]`);
46
- }
22
+ if (operation.endSentenceId < operation.startSentenceId)
23
+ return failure('noncontiguous_range', 'A range must be inclusive and contiguous.', `operations[${index}]`);
24
+ if (operation.text.length > MAX_REPLACEMENT_CHARACTERS)
25
+ return failure('invalid_replacement_text', `Replacement text must contain at most ${MAX_REPLACEMENT_CHARACTERS} characters.`, `operations[${index}].text`);
26
+ }
27
+ if (response.hygieneOperations !== undefined) {
28
+ if (!Array.isArray(response.hygieneOperations))
29
+ return failure('invalid_response_shape', 'Hygiene operations must be an array.', 'hygieneOperations');
30
+ for (const [index, operation] of response.hygieneOperations.entries()) {
31
+ if (!operation || !Number.isInteger(operation.start) || !Number.isInteger(operation.end) || typeof operation.text !== 'string' || operation.end < operation.start) {
32
+ return failure('invalid_response_shape', 'Every hygiene operation requires integer start, end, and string text.', `hygieneOperations[${index}]`);
47
33
  }
48
34
  }
49
- return response;
50
35
  }
51
- if (response.version !== '1')
52
- return failure('invalid_response_version', 'Response version must be "1" or "2".', 'version');
36
+ return response;
37
+ }
38
+ function parseReplacements(response) {
53
39
  if (!Array.isArray(response.replacements))
54
40
  return failure('invalid_response_shape', 'Response replacements must be an array.', 'replacements');
55
41
  if (response.replacements.length > MAX_REPLACEMENTS)
@@ -64,39 +50,62 @@ function parseResponse(value) {
64
50
  }
65
51
  return response;
66
52
  }
67
- function repairStringifiedReplacements(value) {
68
- if (!value || typeof value !== 'object' || Array.isArray(value))
69
- return { value };
70
- const raw = value;
71
- if (typeof raw.replacements !== 'string')
72
- return { value };
73
- try {
74
- const replacements = JSON.parse(raw.replacements);
75
- if (!Array.isArray(replacements))
76
- return { value };
77
- return { value: { ...raw, replacements }, adapterId: 'stringified_replacements_v1' };
78
- }
79
- catch {
80
- return { value };
81
- }
53
+ function parseResponse(value) {
54
+ const raw = typeof value === 'string' ? parseResponseJson(value) : value;
55
+ if (isFailure(raw))
56
+ return raw;
57
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
58
+ return failure('invalid_response_shape', 'Response must be an object.');
59
+ const response = raw;
60
+ if (typeof response.taskFingerprint !== 'string' || response.taskFingerprint.length !== 64)
61
+ return failure('invalid_response_shape', 'Response must include the task fingerprint.', 'taskFingerprint');
62
+ if (response.mode === 'REBUILD')
63
+ return failure('rebuild_response_on_edit_task', 'Rebuild responses cannot satisfy edit tasks.', 'mode');
64
+ if (response.mode === 'SHIP' && response.version === '1')
65
+ return { version: '1', mode: 'SHIP', taskFingerprint: response.taskFingerprint };
66
+ if (response.version === '2')
67
+ return parseRangeResponse(response);
68
+ if (response.version !== '1')
69
+ return failure('invalid_response_version', 'Response version must be "1" or "2".', 'version');
70
+ return parseReplacements(response);
82
71
  }
83
- function repairFencedJson(value) {
84
- if (typeof value !== 'string')
85
- return { value };
86
- const match = value.match(/^```json\s*\n([\s\S]*?)\n```\s*$/i);
87
- return match ? { value: match[1], adapterId: 'fenced_json_v1' } : { value };
72
+ function decodeResponse(raw) {
73
+ const source = typeof raw === 'string' ? parseResponseJson(raw) : raw;
74
+ const response = isFailure(source) ? source : parseResponse(source);
75
+ if (isFailure(response) && response.code === 'invalid_json' && typeof raw === 'string') {
76
+ const fenced = raw.match(/^```json\s*\n([\s\S]*?)\n```\s*$/i);
77
+ if (fenced)
78
+ return { response: parseResponse(fenced[1]), adapterIds: ['fenced_json_v1'] };
79
+ }
80
+ if (isFailure(response) && response.code === 'invalid_response_shape' && source && typeof source === 'object' && !Array.isArray(source)) {
81
+ const value = source;
82
+ if (typeof value.replacements === 'string') {
83
+ let replacements;
84
+ try {
85
+ replacements = JSON.parse(value.replacements);
86
+ }
87
+ catch {
88
+ return { response, adapterIds: [] };
89
+ }
90
+ if (Array.isArray(replacements))
91
+ return { response: parseResponse({ ...value, replacements }), adapterIds: ['stringified_replacements_v1'] };
92
+ }
93
+ }
94
+ return { response, adapterIds: [] };
88
95
  }
89
96
  export function prepareRewriteTask(draft, profile, copySpec, writingBrief, authorizedSentenceIds = []) {
90
97
  const result = analyze(draft, profile, writingBrief);
91
- const prompt = renderRewritePrompt(draft, profile, result, [], writingBrief);
98
+ const factRepair = deriveFactRepair(draft, writingBrief);
99
+ const prompt = renderRewritePrompt(draft, profile, result, [], writingBrief, [], factRepair, authorizedSentenceIds);
92
100
  const mapped = sentences(draft);
93
- const eligibleSentenceIds = new Set([...deriveEditScope(result, true).eligibleSentenceIds, ...authorizedSentenceIds]);
101
+ const eligibleSentenceIds = new Set([...deriveEditScope(result, true).eligibleSentenceIds, ...(factRepair?.eligibleSentenceIds ?? []), ...authorizedSentenceIds]);
94
102
  const taskBase = {
95
103
  version: '1',
96
104
  draft,
97
105
  sentences: mapped.map((sentence) => ({ id: sentence.index, text: sentence.text, eligible: eligibleSentenceIds.has(sentence.index) })),
98
106
  eligibleSentenceIds: [...eligibleSentenceIds].sort((left, right) => left - right),
99
107
  prompt,
108
+ ...(factRepair ? { factRepair } : {}),
100
109
  ...(copySpec ? { copySpec } : {}),
101
110
  ...(writingBrief ? { writingBrief } : {}),
102
111
  };
@@ -135,12 +144,7 @@ export function applyShip(task) {
135
144
  };
136
145
  }
137
146
  export function applyRewriteResponse(task, raw) {
138
- const source = typeof raw === 'string' ? parseResponseJson(raw) : raw;
139
- const parsed = isFailure(source) ? source : parseResponse(source);
140
- const fenced = isFailure(parsed) && parsed.code === 'invalid_json' ? repairFencedJson(raw) : { value: source };
141
- const repaired = isFailure(parsed) && parsed.code === 'invalid_response_shape' ? repairStringifiedReplacements(source) : fenced;
142
- const response = repaired.adapterId ? parseResponse(repaired.value) : parsed;
143
- const adapterIds = repaired.adapterId ? [repaired.adapterId] : [];
147
+ const { response, adapterIds } = decodeResponse(raw);
144
148
  if (isFailure(response))
145
149
  return rejected(task, raw, [response], adapterIds);
146
150
  if (response.taskFingerprint !== task.fingerprint)
@@ -205,10 +209,10 @@ function applyRangeResponse(task, response, raw, adapterIds) {
205
209
  for (const operation of [...(response.hygieneOperations ?? [])].sort((left, right) => right.start - left.start)) {
206
210
  candidate = `${candidate.slice(0, operation.start)}${operation.text}${candidate.slice(operation.end)}`;
207
211
  }
208
- const mapped = sentences(candidate);
212
+ const mapped = new Map(sentences(candidate).map((sentence) => [sentence.index, sentence]));
209
213
  for (const operation of [...response.operations].reverse()) {
210
- const start = mapped.find((sentence) => sentence.index === operation.startSentenceId);
211
- const end = mapped.find((sentence) => sentence.index === operation.endSentenceId);
214
+ const start = mapped.get(operation.startSentenceId);
215
+ const end = mapped.get(operation.endSentenceId);
212
216
  if (!start || !end)
213
217
  return rejected(task, raw, [failure('unknown_sentence_id', 'Range sentenceId is not in this task.', 'operations')], adapterIds);
214
218
  candidate = `${candidate.slice(0, start.start)}${operation.text}${candidate.slice(end.end)}`;
@@ -238,10 +242,53 @@ export function evaluateRewriteResponse(task, raw, profile) {
238
242
  const deterministicArtifact = checked.artifact;
239
243
  if (!verification.passed) {
240
244
  const { candidate: _candidate, ...withheld } = applied;
241
- return { ...withheld, status: 'needs_escalation', verification };
245
+ return { ...withheld, status: 'needs_escalation', verification, feedback: rewriteFeedback(task, candidate, verification, output.changed) };
242
246
  }
243
247
  const lifecycleBinding = createRewriteLifecycleBinding(task, applied.receipt, deterministicArtifact);
244
- return { ...applied, candidate, status: 'needs_semantic_review', verification, deterministicArtifact, lifecycleBinding };
248
+ const pendingFacts = verification.factLint?.findings.filter((finding) => finding.severity !== 'error') ?? [];
249
+ const feedback = pendingFacts.length ? {
250
+ disposition: 'review_required',
251
+ message: 'Deterministic checks passed, but fact findings remain uncertain. Review the cited evidence during semantic review; these findings grant no added edit permission.',
252
+ blockers: pendingFacts.map((finding) => ({ gate: 'facts', disposition: 'review_required', sentenceIds: [finding.draftLocation.sentence], reason: finding.reason })),
253
+ } : undefined;
254
+ return { ...applied, candidate, status: 'needs_semantic_review', verification, deterministicArtifact, lifecycleBinding, ...(feedback ? { feedback } : {}) };
255
+ }
256
+ function rewriteFeedback(task, candidate, verification, hygieneChanged) {
257
+ const sourceHygiene = finalOutputCheck(task.draft);
258
+ const stableSentenceScope = !hygieneChanged && !sourceHygiene.changed && sourceHygiene.accepted;
259
+ const protectedText = new Set(task.sentences.filter((sentence) => !sentence.eligible).map((sentence) => sentence.text));
260
+ const candidateSentences = new Map(sentences(candidate).map((sentence) => [sentence.index, sentence.text]));
261
+ const inScope = (ids) => stableSentenceScope && ids.length > 0 && ids.every((id) => candidateSentences.has(id) && !protectedText.has(candidateSentences.get(id)));
262
+ const blockers = [];
263
+ const add = (gate, reason, sentenceIds = [], repairable = false) => {
264
+ blockers.push({ gate, sentenceIds: [...new Set(sentenceIds)], reason: repairable && !stableSentenceScope ? `${reason} Hygiene normalization prevents reliable original sentence scope matching; review before retrying.` : reason, disposition: repairable && inScope(sentenceIds) ? 'repair_in_scope' : 'review_required' });
265
+ };
266
+ if (verification.strictFindings.length) {
267
+ for (const finding of verification.strictFindings)
268
+ add('analysis', finding.reason, [finding.sentence], true);
269
+ }
270
+ else if (!verification.candidate.passed)
271
+ add('analysis', 'The aggregate analysis gate failed without a sentence-level repair. Review the profile or task scope.');
272
+ for (const finding of verification.factLint?.findings ?? []) {
273
+ if (finding.severity === 'error')
274
+ add('facts', finding.reason, [finding.draftLocation.sentence], isSourceBackedRepair(finding, task.writingBrief?.factSources));
275
+ }
276
+ if (!verification.logicLint.passed)
277
+ add('logic', 'Resolve the reported logic conflict with a reviewer before changing claims.', verification.logicLint.findings.filter((finding) => finding.severity === 'error').map((finding) => finding.sentence));
278
+ if (verification.preservationScore < 70)
279
+ add('preservation', 'The candidate does not preserve enough source content. Review the changes before retrying.');
280
+ if (verification.requiredFacts && !verification.requiredFacts.passed)
281
+ add('required_facts', 'Required facts are missing or denied. Review their compatibility with the authorized edits.');
282
+ if ('claims' in verification && !verification.claims.passed)
283
+ add('copy_spec', 'CopySpec claim verification failed. Review the immutable claims before retrying.');
284
+ if (!verification.finalOutput.accepted)
285
+ add('hygiene', 'Final-output hygiene remains unresolved. Review the reported characters and their permitted removal.');
286
+ if (!blockers.length)
287
+ add('analysis', 'Verification failed without a localized repair. Review the verification report.');
288
+ const disposition = blockers.some((blocker) => blocker.disposition === 'review_required') ? 'review_required' : 'repair_in_scope';
289
+ return { disposition, message: disposition === 'review_required'
290
+ ? 'Stop automatic retries. Resolve the listed blockers with a reviewer or obtain a newly authorized task; protected sentences remain locked.'
291
+ : 'Repair only the listed blockers within this task scope, then verify again. Sentence IDs refer to the evaluated candidate; submit replacements using the original task IDs.', blockers };
245
292
  }
246
293
  export function createRewriteLifecycleBinding(task, receipt, deterministic) {
247
294
  if (!deterministic.passed || receipt.taskFingerprint !== task.fingerprint)
@@ -5,13 +5,6 @@ const RULE_MATCHERS = {
5
5
  'punct.en-dash': /–/u,
6
6
  'format.curly-quotes': /[“”]/u,
7
7
  };
8
- function evidenceDigest(ruleId, samples) {
9
- const evidence = samples
10
- .filter((sample) => RULE_MATCHERS[ruleId].test(sample))
11
- .map((sample) => sample.replace(/\s+/gu, ' ').trim())
12
- .sort();
13
- return createHash('sha256').update(JSON.stringify({ ruleId, evidence })).digest('hex');
14
- }
15
8
  /**
16
9
  * Returns only non-verbatim evidence for stylistic exceptions shown in two or
17
10
  * more author-owned samples. Callers add the result to a signed Profile v3.
@@ -19,9 +12,12 @@ function evidenceDigest(ruleId, samples) {
19
12
  export function deriveRuleAllowances(samples) {
20
13
  const allowances = {};
21
14
  for (const ruleId of SAMPLE_ALLOWANCE_RULE_IDS) {
22
- const count = samples.filter((sample) => RULE_MATCHERS[ruleId].test(sample)).length;
23
- if (count >= 2)
24
- allowances[ruleId] = { sampleCount: count, evidenceDigest: evidenceDigest(ruleId, samples) };
15
+ const matched = samples.filter((sample) => RULE_MATCHERS[ruleId].test(sample));
16
+ if (matched.length < 2)
17
+ continue;
18
+ const evidence = matched.map((sample) => sample.replace(/\s+/gu, ' ').trim()).sort();
19
+ const evidenceDigest = createHash('sha256').update(JSON.stringify({ ruleId, evidence })).digest('hex');
20
+ allowances[ruleId] = { sampleCount: matched.length, evidenceDigest };
25
21
  }
26
22
  return allowances;
27
23
  }
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { canonicalJson } from './canonical-json.js';
3
3
  import { verifyApprovalCapability } from './approval-capability.js';
4
- import { exactKeys as exact, isPlainObject as plain } from './internal.js';
4
+ import { exactKeys as exact, isPlainObject as plain, verificationMatchesBinding } from './internal.js';
5
5
  const violations = new Set(['action_change', 'dropped_object', 'unsupported_claim', 'constraint_weakened', 'clarity_regression']);
6
6
  const HEX = /^[a-f0-9]{64}$/;
7
7
  const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
@@ -94,7 +94,7 @@ export function createInitialLifecycleArtifact(task, deterministic) {
94
94
  const { taskFingerprint, ...taskBase } = task;
95
95
  const { artifactFingerprint, ...deterministicBase } = deterministic;
96
96
  const expectedDeterministicFingerprint = createHash('sha256').update(`hyv:deterministic-verification:v1\0${canonicalJson(deterministicBase)}`).digest('hex');
97
- if (taskFingerprint !== hash('hyv:semantic-task:v1', taskBase) || !deterministic.passed || artifactFingerprint !== expectedDeterministicFingerprint || deterministic.artifactFingerprint !== task.binding.deterministicArtifactFingerprint || deterministic.sourceHash !== task.binding.sourceHash || deterministic.candidateHash !== task.binding.candidateHash || deterministic.profileId !== task.binding.profileId || deterministic.profileRevisionDigest !== task.binding.profileRevisionDigest || deterministic.rulesetVersion !== task.binding.rulesetVersion)
97
+ if (taskFingerprint !== hash('hyv:semantic-task:v1', taskBase) || !deterministic.passed || artifactFingerprint !== expectedDeterministicFingerprint || !verificationMatchesBinding(deterministic, task.binding))
98
98
  throw new Error('Lifecycle creation requires its bound passed deterministic verification artifact.');
99
99
  return artifact({ version: '1', status: 'needs_semantic_review', transitionFingerprint: hash('hyv:lifecycle-initial:v1', { binding: task.binding, semanticPolicy: task.policy }), binding: task.binding, semanticPolicy: task.policy, semanticTaskFingerprint: task.taskFingerprint, semanticEvidenceScopeFingerprint: hash('hyv:semantic-evidence-scope:v1', task.evidenceScope), verdictFingerprints: [] });
100
100
  }
@@ -120,6 +120,72 @@ function normalizeAction(value) {
120
120
  }
121
121
  return undefined;
122
122
  }
123
+ function nextArtifact(current, transitionFingerprint, status, verdictFingerprints, extra = {}) {
124
+ return { ok: true, artifact: artifact({
125
+ version: '1', status, parentArtifactFingerprint: current.artifactFingerprint,
126
+ transitionFingerprint, binding: current.binding, semanticPolicy: current.semanticPolicy,
127
+ semanticTaskFingerprint: current.semanticTaskFingerprint,
128
+ semanticEvidenceScopeFingerprint: current.semanticEvidenceScopeFingerprint,
129
+ verdictFingerprints, ...extra,
130
+ }) };
131
+ }
132
+ function submitReview(current, action, context, transitionFingerprint) {
133
+ if (current.status !== 'needs_semantic_review')
134
+ return fail('out_of_order_transition');
135
+ const required = current.semanticPolicy === 'normal' ? 1 : 3;
136
+ if (action.verdicts.length !== required)
137
+ return fail('invalid_verdict_count');
138
+ const ids = action.verdicts.map((verdict) => verdict.evaluatorId);
139
+ if (new Set(ids).size !== ids.length)
140
+ return fail('duplicate_evaluator');
141
+ const authorized = current.semanticPolicy === 'normal'
142
+ ? context.authorizedSemanticEvaluatorIds.normal
143
+ : context.authorizedSemanticEvaluatorIds.highAssurance;
144
+ if (current.semanticPolicy === 'high_assurance' && new Set(authorized).size < 3)
145
+ return fail('invalid_verdict_count');
146
+ if (ids.some((id) => !authorized.includes(id)))
147
+ return fail('evaluator_not_authorized');
148
+ for (const verdict of action.verdicts) {
149
+ if (action.taskFingerprint !== current.semanticTaskFingerprint || verdict.taskFingerprint !== action.taskFingerprint)
150
+ return fail('task_fingerprint_mismatch');
151
+ if (!same(verdict.binding, current.binding))
152
+ return fail('invalid_binding');
153
+ if (verdict.judgmentType !== 'semantic' || hash('hyv:semantic-evidence-scope:v1', verdict.evidenceScope) !== current.semanticEvidenceScopeFingerprint)
154
+ return fail('evidence_scope_mismatch');
155
+ if ((verdict.approved && verdict.violations.length) || (!verdict.approved && !verdict.violations.length))
156
+ return fail('contradictory_verdict');
157
+ }
158
+ const fingerprints = action.verdicts.map((verdict) => hash('hyv:semantic-verdict:v1', verdict));
159
+ const approvals = action.verdicts.filter((verdict) => verdict.approved).length;
160
+ if (approvals === required)
161
+ return nextArtifact(current, transitionFingerprint, 'ready_for_human_review', fingerprints);
162
+ return nextArtifact(current, transitionFingerprint, 'needs_escalation', fingerprints, {
163
+ reason: approvals === 0 ? 'semantic_rejection' : 'semantic_disagreement',
164
+ });
165
+ }
166
+ function finalizeReview(current, action, context, transitionFingerprint) {
167
+ if (current.status !== 'ready_for_human_review')
168
+ return fail('out_of_order_transition');
169
+ const finalization = action.finalization;
170
+ if (finalization.parentArtifactFingerprint !== current.artifactFingerprint || !same(finalization.binding, current.binding) || finalization.judgmentType !== 'human_finalization' || finalization.evidenceScope.kind !== 'candidate')
171
+ return fail('invalid_binding');
172
+ if (!context.authorizedHumanFinalizerIds.includes(finalization.evaluatorId))
173
+ return fail('human_finalizer_not_authorized');
174
+ if (finalization.decision === 'reject') {
175
+ return nextArtifact(current, transitionFingerprint, 'needs_escalation', current.verdictFingerprints, { reason: 'human_rejection' });
176
+ }
177
+ if (!finalization.capability)
178
+ return fail('capability_required');
179
+ const capability = verifyApprovalCapability(finalization.capability, context.trustStore, {
180
+ now: context.now, expectedSubjectArtifactFingerprint: current.artifactFingerprint,
181
+ binding: current.binding, expectedPurpose: 'hyv.final-approval',
182
+ });
183
+ if (!capability.ok)
184
+ return fail('capability_invalid');
185
+ return nextArtifact(current, transitionFingerprint, 'approved', current.verdictFingerprints, {
186
+ capabilityFingerprint: capability.capabilityFingerprint,
187
+ });
188
+ }
123
189
  export function reduceRewriteLifecycle(current, rawAction, context) {
124
190
  let action;
125
191
  let transitionFingerprint;
@@ -140,49 +206,7 @@ export function reduceRewriteLifecycle(current, rawAction, context) {
140
206
  return fail('stale_parent');
141
207
  if (current.status === 'approved' || current.status === 'needs_escalation')
142
208
  return fail('terminal_state');
143
- if (action.type === 'semantic_submission') {
144
- if (current.status !== 'needs_semantic_review')
145
- return fail('out_of_order_transition');
146
- const required = current.semanticPolicy === 'normal' ? 1 : 3;
147
- if (action.verdicts.length !== required)
148
- return fail('invalid_verdict_count');
149
- const ids = action.verdicts.map((verdict) => verdict.evaluatorId);
150
- if (new Set(ids).size !== ids.length)
151
- return fail('duplicate_evaluator');
152
- const authorized = current.semanticPolicy === 'normal' ? context.authorizedSemanticEvaluatorIds.normal : context.authorizedSemanticEvaluatorIds.highAssurance;
153
- if (current.semanticPolicy === 'high_assurance' && new Set(authorized).size < 3)
154
- return fail('invalid_verdict_count');
155
- if (ids.some((id) => !authorized.includes(id)))
156
- return fail('evaluator_not_authorized');
157
- for (const verdict of action.verdicts) {
158
- if (action.taskFingerprint !== current.semanticTaskFingerprint || verdict.taskFingerprint !== action.taskFingerprint)
159
- return fail('task_fingerprint_mismatch');
160
- if (!same(verdict.binding, current.binding))
161
- return fail('invalid_binding');
162
- if (verdict.judgmentType !== 'semantic' || hash('hyv:semantic-evidence-scope:v1', verdict.evidenceScope) !== current.semanticEvidenceScopeFingerprint)
163
- return fail('evidence_scope_mismatch');
164
- if ((verdict.approved && verdict.violations.length) || (!verdict.approved && !verdict.violations.length))
165
- return fail('contradictory_verdict');
166
- }
167
- const verdictFingerprints = action.verdicts.map((verdict) => hash('hyv:semantic-verdict:v1', verdict));
168
- const approvals = action.verdicts.filter((verdict) => verdict.approved).length;
169
- const status = approvals === required ? 'ready_for_human_review' : 'needs_escalation';
170
- const reason = status === 'needs_escalation' ? (approvals === 0 ? 'semantic_rejection' : 'semantic_disagreement') : undefined;
171
- return { ok: true, artifact: artifact({ version: '1', status, parentArtifactFingerprint: current.artifactFingerprint, transitionFingerprint, binding: current.binding, semanticPolicy: current.semanticPolicy, semanticTaskFingerprint: current.semanticTaskFingerprint, semanticEvidenceScopeFingerprint: current.semanticEvidenceScopeFingerprint, verdictFingerprints, ...(reason ? { reason } : {}) }) };
172
- }
173
- if (current.status !== 'ready_for_human_review')
174
- return fail('out_of_order_transition');
175
- const finalization = action.finalization;
176
- if (finalization.parentArtifactFingerprint !== current.artifactFingerprint || !same(finalization.binding, current.binding) || finalization.judgmentType !== 'human_finalization' || finalization.evidenceScope.kind !== 'candidate')
177
- return fail('invalid_binding');
178
- if (!context.authorizedHumanFinalizerIds.includes(finalization.evaluatorId))
179
- return fail('human_finalizer_not_authorized');
180
- if (finalization.decision === 'reject')
181
- return { ok: true, artifact: artifact({ version: '1', status: 'needs_escalation', parentArtifactFingerprint: current.artifactFingerprint, transitionFingerprint, binding: current.binding, semanticPolicy: current.semanticPolicy, semanticTaskFingerprint: current.semanticTaskFingerprint, semanticEvidenceScopeFingerprint: current.semanticEvidenceScopeFingerprint, verdictFingerprints: current.verdictFingerprints, reason: 'human_rejection' }) };
182
- if (!finalization.capability)
183
- return fail('capability_required');
184
- const capability = verifyApprovalCapability(finalization.capability, context.trustStore, { now: context.now, expectedSubjectArtifactFingerprint: current.artifactFingerprint, binding: current.binding, expectedPurpose: 'hyv.final-approval' });
185
- if (!capability.ok)
186
- return fail('capability_invalid');
187
- return { ok: true, artifact: artifact({ version: '1', status: 'approved', parentArtifactFingerprint: current.artifactFingerprint, transitionFingerprint, binding: current.binding, semanticPolicy: current.semanticPolicy, semanticTaskFingerprint: current.semanticTaskFingerprint, semanticEvidenceScopeFingerprint: current.semanticEvidenceScopeFingerprint, verdictFingerprints: current.verdictFingerprints, capabilityFingerprint: capability.capabilityFingerprint }) };
209
+ return action.type === 'semantic_submission'
210
+ ? submitReview(current, action, context, transitionFingerprint)
211
+ : finalizeReview(current, action, context, transitionFingerprint);
188
212
  }
@@ -449,9 +449,19 @@ function parseReleaseAudit(protocol, input) {
449
449
  fail('release_audit_binding_mismatch');
450
450
  return audit;
451
451
  }
452
- function correctionCount(ratings, mapping, arm, result) {
453
- const label = mapping.labels.A === arm ? 'A' : 'B';
454
- return ratings.filter((rating) => rating.correctionVersusConfirm[label] === result).length;
452
+ function rate(numerator, denominator) {
453
+ return { numerator, denominator, rate: numerator / denominator, uncertainty95: wilson95(numerator, denominator) };
454
+ }
455
+ function corrections(ratings, label, denominator) {
456
+ let corrections = 0;
457
+ let confirms = 0;
458
+ for (const rating of ratings) {
459
+ if (rating.correctionVersusConfirm[label] === 'correction')
460
+ corrections += 1;
461
+ if (rating.correctionVersusConfirm[label] === 'confirm')
462
+ confirms += 1;
463
+ }
464
+ return { corrections, confirms, denominator, correctionRate: corrections / denominator, uncertainty95: wilson95(corrections, denominator) };
455
465
  }
456
466
  function workflowForArm(runs, arm) {
457
467
  const armRuns = runs.filter((run) => run.arm === arm);
@@ -460,21 +470,26 @@ function workflowForArm(runs, arm) {
460
470
  function calculateEvaluation(protocol, runs, ratings, mapping, summary) {
461
471
  const completedRatings = ratings.filter((rating) => rating.workflow === 'completed');
462
472
  const preferenceDenominator = protocol.intentToTreat.expectedReviewers * protocol.cases.length;
463
- const preferred = completedRatings.reduce((total, rating) => total + (rating.preferredLabel === 'tie' ? 0.5 : mapping.labels[rating.preferredLabel] === 'stage1' ? 1 : 0), 0);
473
+ let preferred = 0;
474
+ for (const rating of completedRatings) {
475
+ if (rating.preferredLabel === 'tie')
476
+ preferred += 0.5;
477
+ else if (mapping.labels[rating.preferredLabel] === 'stage1')
478
+ preferred += 1;
479
+ }
464
480
  const preferenceRate = preferenceDenominator ? preferred / preferenceDenominator : 0;
465
- const stage1Confirm = correctionCount(completedRatings, mapping, 'stage1', 'confirm');
466
- const baselineConfirm = correctionCount(completedRatings, mapping, 'baseline', 'confirm');
467
- const stage1Correction = correctionCount(completedRatings, mapping, 'stage1', 'correction');
468
- const baselineCorrection = correctionCount(completedRatings, mapping, 'baseline', 'correction');
481
+ const stage1 = corrections(completedRatings, mapping.labels.A === 'stage1' ? 'A' : 'B', preferenceDenominator);
482
+ const baseline = corrections(completedRatings, mapping.labels.A === 'baseline' ? 'A' : 'B', preferenceDenominator);
469
483
  const baselineWorkflow = workflowForArm(runs, 'baseline');
470
484
  const stage1Workflow = workflowForArm(runs, 'stage1');
471
- const correctionMarginMet = (stage1Confirm - baselineConfirm) / preferenceDenominator >= protocol.analysis.margin;
472
- const workflowRegressed = stage1Workflow.denominator === 0 || baselineWorkflow.denominator === 0 || stage1Workflow.completed / stage1Workflow.denominator < baselineWorkflow.completed / baselineWorkflow.denominator;
485
+ const correctionMarginMet = (stage1.confirms - baseline.confirms) / preferenceDenominator >= protocol.analysis.margin;
486
+ const workflowRegressed = stage1Workflow.denominator === 0 || baselineWorkflow.denominator === 0
487
+ || stage1Workflow.completed / stage1Workflow.denominator < baselineWorkflow.completed / baselineWorkflow.denominator;
473
488
  const metrics = {
474
- preference: { numerator: preferred, denominator: preferenceDenominator, rate: preferenceRate, uncertainty95: wilson95(preferred, preferenceDenominator) },
475
- correctionVersusConfirm: { stage1: { corrections: stage1Correction, confirms: stage1Confirm, denominator: preferenceDenominator, correctionRate: stage1Correction / preferenceDenominator, uncertainty95: wilson95(stage1Correction, preferenceDenominator) }, baseline: { corrections: baselineCorrection, confirms: baselineConfirm, denominator: preferenceDenominator, correctionRate: baselineCorrection / preferenceDenominator, uncertainty95: wilson95(baselineCorrection, preferenceDenominator) } },
476
- completion: { numerator: completedRatings.length, denominator: preferenceDenominator, rate: completedRatings.length / preferenceDenominator, uncertainty95: wilson95(completedRatings.length, preferenceDenominator) },
477
- abandonment: { numerator: ratings.length - completedRatings.length, denominator: preferenceDenominator, rate: (ratings.length - completedRatings.length) / preferenceDenominator, uncertainty95: wilson95(ratings.length - completedRatings.length, preferenceDenominator) },
489
+ preference: { ...rate(preferred, preferenceDenominator), rate: preferenceRate },
490
+ correctionVersusConfirm: { stage1, baseline },
491
+ completion: rate(completedRatings.length, preferenceDenominator),
492
+ abandonment: rate(ratings.length - completedRatings.length, preferenceDenominator),
478
493
  providerRuns: { completed: summary.completed, abandoned: summary.abandonments, hardFailures: summary.hardFailures, timeouts: summary.timeouts, denominator: summary.denominator },
479
494
  };
480
495
  return { correctionMarginMet, metrics, preferenceDenominator, preferenceRate, workflowRegressed };
@@ -6,25 +6,17 @@ const MINIMUM_WORDS = 1_500;
6
6
  function finding(id, disposition, reason, suggestion, sentence) {
7
7
  return { id, disposition, reason, suggestion, ...(sentence === undefined ? {} : { sentence }) };
8
8
  }
9
+ const FINGERPRINT_KEYS = new Map([
10
+ ['dna.fingerprint.contraction-rate', 'contractionRate'],
11
+ ['dna.fingerprint.sentence-length-distribution', 'sentenceLengthDistribution'],
12
+ ['dna.fingerprint.bullet-rate', 'bulletRate'],
13
+ ['dna.fingerprint.en-dash-rate', 'enDashRate'],
14
+ ]);
9
15
  function strictFinding(source, profile) {
10
- if (source.engine === 'ai_editor') {
11
- return finding(`strict.${source.engine}.${source.id}`, 'block', source.reason, source.suggestion, source.sentence);
12
- }
13
- if (source.engine === 'voice_dna') {
14
- if (source.severity === 'red')
15
- return finding(`strict.${source.engine}.${source.id}`, 'block', source.reason, source.suggestion, source.sentence);
16
- if (source.id.startsWith('dna.fingerprint.')) {
17
- const metric = source.id.slice('dna.fingerprint.'.length);
18
- const key = metric === 'contraction-rate' ? 'contractionRate'
19
- : metric === 'sentence-length-distribution' ? 'sentenceLengthDistribution'
20
- : metric === 'bullet-rate' ? 'bulletRate'
21
- : metric === 'en-dash-rate' ? 'enDashRate' : undefined;
22
- if (key && profile.tolerances[key].calibrated)
23
- return finding(`strict.${source.engine}.${source.id}`, 'block', source.reason, source.suggestion, source.sentence);
24
- }
25
- return finding(`strict.${source.engine}.${source.id}`, 'review', source.reason, source.suggestion, source.sentence);
26
- }
27
- return finding(`strict.${source.engine}.${source.id}`, source.severity === 'red' ? 'block' : 'review', source.reason, source.suggestion, source.sentence);
16
+ const metric = FINGERPRINT_KEYS.get(source.id);
17
+ const calibratedDrift = source.engine === 'voice_dna' && metric && profile.tolerances[metric].calibrated;
18
+ const blocks = source.engine === 'ai_editor' || source.severity === 'red' || calibratedDrift;
19
+ return finding(`strict.${source.engine}.${source.id}`, blocks ? 'block' : 'review', source.reason, source.suggestion, source.sentence);
28
20
  }
29
21
  export function evaluateStrictQuality(draft, profile, samples, brief) {
30
22
  const readiness = assessProfileReadiness(samples);
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const HYV_VERSION = '3.6.1';
1
+ export const HYV_VERSION = '4.0.1';