@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
@@ -9,47 +9,32 @@ const MAX_REPLACEMENT_CHARACTERS = 10_000;
9
9
  function isFailure(value) {
10
10
  return typeof value === 'object' && value !== null && 'code' in value;
11
11
  }
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`);
12
+ function parseRangeResponse(response) {
13
+ if (!Array.isArray(response.operations))
14
+ return failure('invalid_response_shape', 'Version 2 responses require an operations array.', 'operations');
15
+ if (response.operations.length > MAX_REPLACEMENTS)
16
+ return failure('invalid_response_shape', `Response may include at most ${MAX_REPLACEMENTS} replacements.`, 'operations');
17
+ for (const [index, operation] of response.operations.entries()) {
18
+ if (!operation || typeof operation !== 'object' || !Number.isInteger(operation.startSentenceId) || !Number.isInteger(operation.endSentenceId) || typeof operation.text !== 'string') {
19
+ return failure('invalid_response_shape', 'Every operation requires integer startSentenceId, endSentenceId, and string text.', `operations[${index}]`);
39
20
  }
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
- }
21
+ if (operation.endSentenceId < operation.startSentenceId)
22
+ return failure('noncontiguous_range', 'A range must be inclusive and contiguous.', `operations[${index}]`);
23
+ if (operation.text.length > MAX_REPLACEMENT_CHARACTERS)
24
+ return failure('invalid_replacement_text', `Replacement text must contain at most ${MAX_REPLACEMENT_CHARACTERS} characters.`, `operations[${index}].text`);
25
+ }
26
+ if (response.hygieneOperations !== undefined) {
27
+ if (!Array.isArray(response.hygieneOperations))
28
+ return failure('invalid_response_shape', 'Hygiene operations must be an array.', 'hygieneOperations');
29
+ for (const [index, operation] of response.hygieneOperations.entries()) {
30
+ if (!operation || !Number.isInteger(operation.start) || !Number.isInteger(operation.end) || typeof operation.text !== 'string' || operation.end < operation.start) {
31
+ return failure('invalid_response_shape', 'Every hygiene operation requires integer start, end, and string text.', `hygieneOperations[${index}]`);
47
32
  }
48
33
  }
49
- return response;
50
34
  }
51
- if (response.version !== '1')
52
- return failure('invalid_response_version', 'Response version must be "1" or "2".', 'version');
35
+ return response;
36
+ }
37
+ function parseReplacements(response) {
53
38
  if (!Array.isArray(response.replacements))
54
39
  return failure('invalid_response_shape', 'Response replacements must be an array.', 'replacements');
55
40
  if (response.replacements.length > MAX_REPLACEMENTS)
@@ -64,27 +49,48 @@ function parseResponse(value) {
64
49
  }
65
50
  return response;
66
51
  }
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' };
52
+ function parseResponse(value) {
53
+ const raw = typeof value === 'string' ? parseResponseJson(value) : value;
54
+ if (isFailure(raw))
55
+ return raw;
56
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
57
+ return failure('invalid_response_shape', 'Response must be an object.');
58
+ const response = raw;
59
+ if (typeof response.taskFingerprint !== 'string' || response.taskFingerprint.length !== 64)
60
+ return failure('invalid_response_shape', 'Response must include the task fingerprint.', 'taskFingerprint');
61
+ if (response.mode === 'REBUILD')
62
+ return failure('rebuild_response_on_edit_task', 'Rebuild responses cannot satisfy edit tasks.', 'mode');
63
+ if (response.mode === 'SHIP' && response.version === '1')
64
+ return { version: '1', mode: 'SHIP', taskFingerprint: response.taskFingerprint };
65
+ if (response.version === '2')
66
+ return parseRangeResponse(response);
67
+ if (response.version !== '1')
68
+ return failure('invalid_response_version', 'Response version must be "1" or "2".', 'version');
69
+ return parseReplacements(response);
70
+ }
71
+ function decodeResponse(raw) {
72
+ const source = typeof raw === 'string' ? parseResponseJson(raw) : raw;
73
+ const response = isFailure(source) ? source : parseResponse(source);
74
+ if (isFailure(response) && response.code === 'invalid_json' && typeof raw === 'string') {
75
+ const fenced = raw.match(/^```json\s*\n([\s\S]*?)\n```\s*$/i);
76
+ if (fenced)
77
+ return { response: parseResponse(fenced[1]), adapterIds: ['fenced_json_v1'] };
78
78
  }
79
- catch {
80
- return { value };
79
+ if (isFailure(response) && response.code === 'invalid_response_shape' && source && typeof source === 'object' && !Array.isArray(source)) {
80
+ const value = source;
81
+ if (typeof value.replacements === 'string') {
82
+ let replacements;
83
+ try {
84
+ replacements = JSON.parse(value.replacements);
85
+ }
86
+ catch {
87
+ return { response, adapterIds: [] };
88
+ }
89
+ if (Array.isArray(replacements))
90
+ return { response: parseResponse({ ...value, replacements }), adapterIds: ['stringified_replacements_v1'] };
91
+ }
81
92
  }
82
- }
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 };
93
+ return { response, adapterIds: [] };
88
94
  }
89
95
  export function prepareRewriteTask(draft, profile, copySpec, writingBrief, authorizedSentenceIds = []) {
90
96
  const result = analyze(draft, profile, writingBrief);
@@ -135,12 +141,7 @@ export function applyShip(task) {
135
141
  };
136
142
  }
137
143
  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] : [];
144
+ const { response, adapterIds } = decodeResponse(raw);
144
145
  if (isFailure(response))
145
146
  return rejected(task, raw, [response], adapterIds);
146
147
  if (response.taskFingerprint !== task.fingerprint)
@@ -205,10 +206,10 @@ function applyRangeResponse(task, response, raw, adapterIds) {
205
206
  for (const operation of [...(response.hygieneOperations ?? [])].sort((left, right) => right.start - left.start)) {
206
207
  candidate = `${candidate.slice(0, operation.start)}${operation.text}${candidate.slice(operation.end)}`;
207
208
  }
208
- const mapped = sentences(candidate);
209
+ const mapped = new Map(sentences(candidate).map((sentence) => [sentence.index, sentence]));
209
210
  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);
211
+ const start = mapped.get(operation.startSentenceId);
212
+ const end = mapped.get(operation.endSentenceId);
212
213
  if (!start || !end)
213
214
  return rejected(task, raw, [failure('unknown_sentence_id', 'Range sentenceId is not in this task.', 'operations')], adapterIds);
214
215
  candidate = `${candidate.slice(0, start.start)}${operation.text}${candidate.slice(end.end)}`;
@@ -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.0';
package/dist/voice-dna.js CHANGED
@@ -16,12 +16,18 @@ function top(items, limit) {
16
16
  counts.set(item, (counts.get(item) ?? 0) + 1);
17
17
  return [...counts].sort((left, right) => right[1] - left[1]).slice(0, limit).map(([item]) => item);
18
18
  }
19
+ function measureWriting(text, tokens) {
20
+ const mapped = sentences(text);
21
+ return { text, mapped, tokens: tokens ?? words(text), sentenceWords: mapped.map((sentence) => words(sentence.text)) };
22
+ }
19
23
  export function profileMetrics(text) {
20
- const draftSentences = sentences(text);
21
- const draftWords = words(text);
22
- const lengths = draftSentences.map((sentence) => words(sentence.text).length);
23
- const starters = draftSentences.map((sentence) => words(sentence.text)[0]?.toLowerCase() ?? '');
24
- const structures = draftSentences.map((sentence) => words(sentence.text).slice(0, 3).map((word) => word.toLowerCase()).join(' '));
24
+ return metricsFor(measureWriting(text));
25
+ }
26
+ function metricsFor(writing) {
27
+ const { text, mapped: draftSentences, tokens: draftWords, sentenceWords } = writing;
28
+ const lengths = sentenceWords.map((tokens) => tokens.length);
29
+ const starters = sentenceWords.map((tokens) => tokens[0]?.toLowerCase() ?? '');
30
+ const structures = sentenceWords.map((tokens) => tokens.slice(0, 3).map((word) => word.toLowerCase()).join(' '));
25
31
  const lowerWords = draftWords.map((word) => word.toLowerCase());
26
32
  const nonStop = lowerWords.filter((word) => word.length > 3 && !STOP_WORDS.has(word));
27
33
  const first = lowerWords.filter((word) => ['i', 'we', 'my', 'our', 'us'].includes(word)).length;
@@ -52,17 +58,18 @@ export function profileMetrics(text) {
52
58
  };
53
59
  }
54
60
  export function measureFounderFingerprint(text) {
55
- const draftWords = words(text);
61
+ return fingerprintFor(measureWriting(text, words(text)));
62
+ }
63
+ function fingerprintFor(writing) {
64
+ const { text, mapped: draftSentences, tokens: draftWords, sentenceWords } = writing;
56
65
  const wordDenominator = Math.max(1, draftWords.length);
57
66
  const contractionCount = draftWords.filter((word) => CONTRACTIONS.has(word.toLowerCase().replaceAll('’', "'"))).length;
58
- const draftSentences = sentences(text);
59
67
  const sentenceDenominator = Math.max(1, draftSentences.length);
60
68
  const buckets = { short: 0, medium: 0, long: 0 };
61
- for (const sentence of draftSentences) {
62
- const length = words(sentence.text).length;
63
- if (length <= 8)
69
+ for (const tokens of sentenceWords) {
70
+ if (tokens.length <= 8)
64
71
  buckets.short += 1;
65
- else if (length <= 20)
72
+ else if (tokens.length <= 20)
66
73
  buckets.medium += 1;
67
74
  else
68
75
  buckets.long += 1;
@@ -81,16 +88,21 @@ export function measureFounderFingerprint(text) {
81
88
  enDashRate: (text.match(/–/g) ?? []).length / wordDenominator,
82
89
  };
83
90
  }
84
- export function buildProfile(samples, avoid = []) {
91
+ function measureSamples(samples, avoid) {
85
92
  if (samples.length < 2)
86
93
  throw new Error('Provide at least two local writing samples.');
87
94
  if (samples.some((sample) => !words(sample).length))
88
95
  throw new Error('Every local writing sample must contain writing.');
89
- return { version: '2', sampleCount: samples.length, metrics: profileMetrics(samples.join('\n\n')), avoid };
96
+ const writing = measureWriting(samples.join('\n\n'));
97
+ const profile = { version: '2', sampleCount: samples.length, metrics: metricsFor(writing), avoid };
98
+ return { writing, profile };
99
+ }
100
+ export function buildProfile(samples, avoid = []) {
101
+ return measureSamples(samples, avoid).profile;
90
102
  }
91
103
  export function buildProfileV3(samples, id, channel, avoid = [], tone) {
92
- const base = buildProfile(samples, avoid);
93
- const sampleText = samples.join('\n\n');
104
+ const { profile: base, writing } = measureSamples(samples, avoid);
105
+ const ruleAllowances = deriveRuleAllowances(samples);
94
106
  const fixtureIds = samples.map((_, index) => 'sample.' + String(index + 1).padStart(3, '0'));
95
107
  const unsigned = {
96
108
  version: '3',
@@ -101,10 +113,10 @@ export function buildProfileV3(samples, id, channel, avoid = [], tone) {
101
113
  avoid: base.avoid,
102
114
  provenance: { source: 'local-author-owned-samples', rights: 'author-owned', createdAt: new Date().toISOString() },
103
115
  rulePolicy: {},
104
- ...(Object.keys(deriveRuleAllowances(samples)).length ? { ruleAllowances: deriveRuleAllowances(samples) } : {}),
116
+ ...(Object.keys(ruleAllowances).length ? { ruleAllowances } : {}),
105
117
  channel,
106
118
  ...(tone ? { tone } : {}),
107
- fingerprint: measureFounderFingerprint(sampleText),
119
+ fingerprint: fingerprintFor(writing),
108
120
  tolerances: {
109
121
  contractionRate: { absolute: 0.1, calibrated: false },
110
122
  sentenceLengthDistribution: { absolute: 0.15, calibrated: false },
@@ -121,11 +133,12 @@ function finding(id, severity, sentence, excerpt, reason, suggestion) {
121
133
  return { engine: 'voice_dna', id, severity, sentence, excerpt, reason, suggestion };
122
134
  }
123
135
  export function analyzeVoiceDna(text, profile) {
124
- const metrics = profileMetrics(text);
136
+ const writing = measureWriting(text);
137
+ const metrics = metricsFor(writing);
125
138
  const findings = [];
126
139
  const tolerance = Math.max(8, profile.metrics.sentenceVariation * 2.2);
127
- for (const sentence of sentences(text)) {
128
- const count = words(sentence.text).length;
140
+ for (const [index, sentence] of writing.mapped.entries()) {
141
+ const count = writing.sentenceWords[index].length;
129
142
  if (Math.abs(count - profile.metrics.sentenceLength) > tolerance)
130
143
  findings.push(finding('dna.sentence-length', 'yellow', sentence.index, sentence.text, `Sentence length (${count}) is outside the profile band around ${profile.metrics.sentenceLength}.`, 'Restore the writer’s usual sentence length where it improves clarity.'));
131
144
  for (const banned of profile.avoid)
@@ -139,7 +152,7 @@ export function analyzeVoiceDna(text, profile) {
139
152
  if (metrics.pointOfView !== profile.metrics.pointOfView && profile.metrics.pointOfView !== 'mixed')
140
153
  findings.push(finding('dna.point-of-view', 'yellow', 1, text.slice(0, 160), `Draft point of view is ${metrics.pointOfView}; profile is ${profile.metrics.pointOfView}.`, 'Restore the writer’s normal narrative distance.'));
141
154
  if (profile.version === '3') {
142
- const measured = measureFounderFingerprint(text);
155
+ const measured = fingerprintFor(writing);
143
156
  const fingerprintChecks = [
144
157
  ['contraction-rate', measured.contractionRate, profile.fingerprint.contractionRate, profile.tolerances.contractionRate],
145
158
  ['sentence-length-distribution', Math.max(Math.abs(measured.sentenceLengthDistribution.short - profile.fingerprint.sentenceLengthDistribution.short), Math.abs(measured.sentenceLengthDistribution.medium - profile.fingerprint.sentenceLengthDistribution.medium), Math.abs(measured.sentenceLengthDistribution.long - profile.fingerprint.sentenceLengthDistribution.long)), 0, profile.tolerances.sentenceLengthDistribution],
@@ -31,19 +31,14 @@ function validate(inputs) {
31
31
  function indexExamples(inputs) {
32
32
  const entries = inputs.flatMap((input) => sentences(input.text).map((sentence, position) => ({ source: input.basename, position, text: sentence.text, terms: tokens(sentence.text) })));
33
33
  const postings = new Map();
34
- const documentFrequency = new Map();
35
34
  for (const [entryIndex, entry] of entries.entries()) {
36
- const seen = new Set();
37
35
  for (const term of entry.terms) {
38
36
  const posting = postings.get(term) ?? new Map();
39
37
  posting.set(entryIndex, (posting.get(entryIndex) ?? 0) + 1);
40
38
  postings.set(term, posting);
41
- seen.add(term);
42
39
  }
43
- for (const term of seen)
44
- documentFrequency.set(term, (documentFrequency.get(term) ?? 0) + 1);
45
40
  }
46
- return { entries, postings, documentFrequency };
41
+ return { entries, postings };
47
42
  }
48
43
  /** Finds redacted local examples in supplied memory only; it never creates a corpus or index on disk. */
49
44
  export function findWritingExamples(query, inputs, limit = 3) {
@@ -58,7 +53,7 @@ export function findWritingExamples(query, inputs, limit = 3) {
58
53
  const posting = index.postings.get(term);
59
54
  if (!posting)
60
55
  continue;
61
- const idf = Math.log(1 + (index.entries.length - (index.documentFrequency.get(term) ?? 0) + 0.5) / ((index.documentFrequency.get(term) ?? 0) + 0.5));
56
+ const idf = Math.log(1 + (index.entries.length - posting.size + 0.5) / (posting.size + 0.5));
62
57
  for (const [entryIndex, frequency] of posting) {
63
58
  const entry = index.entries[entryIndex];
64
59
  const denominator = frequency + 1.2 * (1 - 0.75 + 0.75 * entry.terms.length / Math.max(1, averageLength));