@holdyourvoice/hyv 3.2.0 → 3.3.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.
- package/Readme.md +23 -10
- package/dist/ai-editor-rules.js +5 -2
- package/dist/ai-editor.js +52 -9
- package/dist/ai-editor.test.js +62 -10
- package/dist/approval-capability.js +111 -0
- package/dist/approval-capability.test.js +52 -0
- package/dist/approval-context.js +54 -0
- package/dist/approval-context.test.js +38 -0
- package/dist/benchmark.js +232 -0
- package/dist/benchmark.test.js +328 -0
- package/dist/canonical-json.js +123 -0
- package/dist/canonical-json.test.js +24 -0
- package/dist/cli.js +272 -19
- package/dist/cli.test.js +205 -8
- package/dist/hygiene.js +6 -0
- package/dist/hygiene.test.js +7 -1
- package/dist/judgment-task.js +171 -0
- package/dist/judgment-task.test.js +162 -0
- package/dist/learning.js +240 -100
- package/dist/learning.test.js +203 -3
- package/dist/lifecycle-adapter.js +75 -0
- package/dist/lifecycle-adapter.test.js +56 -0
- package/dist/mcp-tools.js +101 -7
- package/dist/mcp-tools.test.js +156 -6
- package/dist/mcp.js +213 -6
- package/dist/mcp.test.js +210 -11
- package/dist/pipeline.js +78 -14
- package/dist/pipeline.test.js +36 -2
- package/dist/preservation.js +89 -0
- package/dist/preservation.test.js +22 -0
- package/dist/profile.js +87 -0
- package/dist/profile.test.js +114 -0
- package/dist/rebuild-task.js +226 -0
- package/dist/rebuild-task.test.js +179 -0
- package/dist/release-audit.test.js +111 -2
- package/dist/rewrite-task.js +136 -16
- package/dist/rewrite-task.test.js +62 -7
- package/dist/rule-reconciliation.test.js +50 -0
- package/dist/semantic-review.js +176 -7
- package/dist/semantic-review.test.js +98 -14
- package/dist/stage1-dry-run.test.js +39 -0
- package/dist/stage1-evaluation.js +579 -0
- package/dist/stage1-evaluation.test.js +184 -0
- package/dist/stage1-human-packet.test.js +102 -0
- package/dist/stage1-schema-contract.test.js +95 -0
- package/dist/stage2-human-packet.test.js +81 -0
- package/dist/version.js +1 -1
- package/dist/voice-dna.js +53 -1
- package/dist/voice-dna.test.js +79 -1
- package/package.json +2 -2
package/dist/mcp.test.js
CHANGED
|
@@ -1,12 +1,54 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
+
import { createHash, generateKeyPairSync, sign } from 'node:crypto';
|
|
2
3
|
import { spawn } from 'node:child_process';
|
|
3
4
|
import { once } from 'node:events';
|
|
4
|
-
import {
|
|
5
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
6
|
import { tmpdir } from 'node:os';
|
|
6
7
|
import { join } from 'node:path';
|
|
7
8
|
import test from 'node:test';
|
|
8
9
|
import { profileFingerprint } from './learning.js';
|
|
9
10
|
import { buildProfile } from './voice-dna.js';
|
|
11
|
+
import { canonicalJson } from './canonical-json.js';
|
|
12
|
+
import { applyRewriteForMcp, prepareLifecycleForMcp, prepareRewriteForMcp } from './mcp-tools.js';
|
|
13
|
+
function profileV3Json() {
|
|
14
|
+
const profile = buildProfile(['I write plainly. I name the work.', 'I keep the mechanism clear. I avoid filler.'], ['leverage']);
|
|
15
|
+
const unsigned = { ...profile, version: '3', id: 'founder.test', revision: 1, provenance: { source: 'test', rights: 'test', createdAt: '2026-08-13T00:00:00.000Z' }, rulePolicy: {}, fingerprint: { contractionRate: 0, sentenceLengthDistribution: { short: 1, medium: 0, long: 0 }, bulletRate: 0, enDashRate: 0 }, tolerances: { contractionRate: { absolute: 0.1, calibrated: false }, sentenceLengthDistribution: { absolute: 0.1, calibrated: false }, bulletRate: { absolute: 0.1, calibrated: false }, enDashRate: { absolute: 0.1, calibrated: false } }, metricFixtures: { contractionRate: ['test'], sentenceLengthDistribution: ['test'], bulletRate: ['test'], enDashRate: ['test'] } };
|
|
16
|
+
const canonical = (value) => Array.isArray(value) ? `[${value.map(canonical).join(',')}]` : value && typeof value === 'object' ? `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}` : JSON.stringify(value);
|
|
17
|
+
return JSON.stringify({ ...unsigned, revisionDigest: createHash('sha256').update(canonical(unsigned)).digest('hex') });
|
|
18
|
+
}
|
|
19
|
+
async function callMcp(tools, env = process.env) {
|
|
20
|
+
const server = spawn(process.execPath, [new URL('./cli.js', import.meta.url).pathname, 'mcp'], { stdio: ['pipe', 'pipe', 'pipe'], env });
|
|
21
|
+
let stdout = '';
|
|
22
|
+
let stderr = '';
|
|
23
|
+
server.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
24
|
+
server.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
25
|
+
server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test', version: '1.0.0' } } })}\n`);
|
|
26
|
+
server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} })}\n`);
|
|
27
|
+
tools.forEach((tool, index) => server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: index + 2, method: 'tools/call', params: tool })}\n`));
|
|
28
|
+
server.stdin.end();
|
|
29
|
+
const [code] = await once(server, 'close');
|
|
30
|
+
assert.equal(code, 0);
|
|
31
|
+
assert.equal(stderr, '');
|
|
32
|
+
return stdout.trim().split('\n').map((line) => JSON.parse(line)).filter((response) => response.id >= 2).sort((a, b) => a.id - b.id);
|
|
33
|
+
}
|
|
34
|
+
function toolPayload(response) {
|
|
35
|
+
return JSON.parse(response.result?.content?.[0]?.text ?? '{}');
|
|
36
|
+
}
|
|
37
|
+
function installedContextEnvironment(root, context) {
|
|
38
|
+
const home = join(root, 'home');
|
|
39
|
+
const config = join(home, '.config', 'holdyourvoice');
|
|
40
|
+
mkdirSync(config, { recursive: true, mode: 0o700 });
|
|
41
|
+
const contextPath = join(config, 'approval-context.json');
|
|
42
|
+
writeFileSync(contextPath, canonicalJson(context), { mode: 0o600 });
|
|
43
|
+
chmodSync(contextPath, 0o600);
|
|
44
|
+
const fakeOs = join(root, 'fake-os.mjs');
|
|
45
|
+
const hooks = join(root, 'hooks.mjs');
|
|
46
|
+
const register = join(root, 'register.mjs');
|
|
47
|
+
writeFileSync(fakeOs, `import * as actual from 'node:os'; export const userInfo = () => ({ ...actual.userInfo(), homedir: process.env.HYV_TEST_HOME });\n`);
|
|
48
|
+
writeFileSync(hooks, `export async function resolve(specifier, context, nextResolve) { if (specifier === 'node:os' && context.parentURL?.endsWith('/approval-context.js')) return { url: new URL('./fake-os.mjs', import.meta.url).href, shortCircuit: true }; return nextResolve(specifier, context); }\n`);
|
|
49
|
+
writeFileSync(register, `import { register } from 'node:module'; register(new URL('./hooks.mjs', import.meta.url));\n`);
|
|
50
|
+
return { ...process.env, NODE_NO_WARNINGS: '1', NODE_OPTIONS: `--import=${register}`, HYV_TEST_HOME: home };
|
|
51
|
+
}
|
|
10
52
|
test('serves local Claude tools over stdio', async () => {
|
|
11
53
|
const server = spawn(process.execPath, [new URL('./cli.js', import.meta.url).pathname, 'mcp'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
12
54
|
let stdout = '';
|
|
@@ -22,10 +64,170 @@ test('serves local Claude tools over stdio', async () => {
|
|
|
22
64
|
assert.equal(code, 0);
|
|
23
65
|
const responses = stdout.trim().split('\n').map((line) => JSON.parse(line));
|
|
24
66
|
const tools = responses.find((response) => response.id === 2)?.result?.tools;
|
|
25
|
-
assert.deepEqual(tools?.map((tool) => tool.name), ['hyv_build_profile', 'hyv_analyze', 'hyv_hygiene', 'hyv_final_check', 'hyv_rewrite_prompt', 'hyv_prepare_rewrite', 'hyv_apply_rewrite', 'hyv_verify', 'hyv_verify_copy_spec', 'hyv_batch_analyze', 'hyv_patterns']);
|
|
26
|
-
assert.ok(tools?.filter((tool) =>
|
|
27
|
-
assert.equal(tools?.find((tool) => tool.name === 'hyv_verify')?.annotations?.readOnlyHint,
|
|
28
|
-
assert.equal(tools?.find((tool) => tool.name === 'hyv_verify_copy_spec')?.annotations?.readOnlyHint,
|
|
67
|
+
assert.deepEqual(tools?.map((tool) => tool.name), ['hyv_build_profile', 'hyv_analyze', 'hyv_hygiene', 'hyv_final_check', 'hyv_rewrite_prompt', 'hyv_prepare_rewrite', 'hyv_apply_rewrite', 'hyv_prepare_judgment', 'hyv_reduce_judgment', 'hyv_verify', 'hyv_verify_copy_spec', 'hyv_batch_analyze', 'hyv_patterns', 'hyv_learning_inspect', 'hyv_learning_record', 'hyv_learning_ratify', 'hyv_learning_supersede', 'hyv_learning_migrate', 'hyv_learning_clear', 'hyv_lifecycle_prepare_semantic', 'hyv_lifecycle_submit_verdict', 'hyv_lifecycle_inspect', 'hyv_lifecycle_finalize']);
|
|
68
|
+
assert.ok(tools?.filter((tool) => !['hyv_verify', 'hyv_verify_copy_spec', 'hyv_learning_record', 'hyv_learning_ratify', 'hyv_learning_supersede', 'hyv_learning_migrate', 'hyv_learning_clear'].includes(tool.name)).every((tool) => tool.annotations?.readOnlyHint));
|
|
69
|
+
assert.equal(tools?.find((tool) => tool.name === 'hyv_verify')?.annotations?.readOnlyHint, true);
|
|
70
|
+
assert.equal(tools?.find((tool) => tool.name === 'hyv_verify_copy_spec')?.annotations?.readOnlyHint, true);
|
|
71
|
+
assert.equal(tools?.some((tool) => tool.name === 'hyv_lifecycle_validate_final_approval'), false);
|
|
72
|
+
assert.equal(tools?.some((tool) => tool.name === 'hyv_learning_record_approved'), false);
|
|
73
|
+
assert.equal(tools?.some((tool) => tool.name === 'hyv_prepare_rebuild'), false);
|
|
74
|
+
assert.equal(tools?.some((tool) => tool.name === 'hyv_apply_rebuild'), false);
|
|
75
|
+
assert.equal('capability_json' in (tools?.find((tool) => tool.name === 'hyv_lifecycle_finalize')?.inputSchema?.properties ?? {}), false);
|
|
76
|
+
assert.equal(tools?.find((tool) => tool.name === 'hyv_learning_inspect')?.annotations?.readOnlyHint, true);
|
|
77
|
+
assert.equal(tools?.find((tool) => tool.name === 'hyv_learning_clear')?.annotations?.readOnlyHint, false);
|
|
78
|
+
assert.deepEqual(tools?.filter((tool) => tool.name.startsWith('hyv_learning_')).map((tool) => [tool.name, tool.annotations?.readOnlyHint, tool.annotations?.destructiveHint]), [
|
|
79
|
+
['hyv_learning_inspect', true, undefined], ['hyv_learning_record', false, false], ['hyv_learning_ratify', false, false],
|
|
80
|
+
['hyv_learning_supersede', false, true], ['hyv_learning_migrate', false, false], ['hyv_learning_clear', false, true],
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
test('registers capability tools only with host redaction attestation', async () => {
|
|
84
|
+
const server = spawn(process.execPath, [new URL('./cli.js', import.meta.url).pathname, 'mcp'], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, HYV_MCP_SENSITIVE_INPUT_REDACTION: '1' } });
|
|
85
|
+
let stdout = '';
|
|
86
|
+
let stderr = '';
|
|
87
|
+
server.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
88
|
+
server.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
89
|
+
server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test', version: '1.0.0' } } })}\n`);
|
|
90
|
+
server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} })}\n`);
|
|
91
|
+
server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} })}\n`);
|
|
92
|
+
server.stdin.end();
|
|
93
|
+
const [code] = await once(server, 'close');
|
|
94
|
+
assert.equal(code, 0);
|
|
95
|
+
assert.equal(stderr, '');
|
|
96
|
+
const response = stdout.trim().split('\n').map((line) => JSON.parse(line)).find((item) => item.id === 2);
|
|
97
|
+
const names = response.result.tools.map((tool) => tool.name);
|
|
98
|
+
assert.equal(names.length, 27);
|
|
99
|
+
assert.ok(names.includes('hyv_lifecycle_validate_final_approval'));
|
|
100
|
+
assert.ok(names.includes('hyv_learning_record_approved'));
|
|
101
|
+
assert.ok(names.includes('hyv_prepare_rebuild'));
|
|
102
|
+
assert.ok(names.includes('hyv_apply_rebuild'));
|
|
103
|
+
const finalize = response.result.tools.find((tool) => tool.name === 'hyv_lifecycle_finalize');
|
|
104
|
+
assert.equal('capability_json' in finalize.inputSchema.properties, true);
|
|
105
|
+
});
|
|
106
|
+
test('redacts rebuild prepare transport failures', async () => {
|
|
107
|
+
const secret = 'rebuild-prepare-secret-nonce';
|
|
108
|
+
const responses = await callMcp([{ name: 'hyv_prepare_rebuild', arguments: { draft: 'x', profile_json: '{}', reduction_json: '{}', copy_spec_json: '{}', capability_json: JSON.stringify({ payload: secret, signature: secret }) } }], { ...process.env, HYV_MCP_SENSITIVE_INPUT_REDACTION: '1' });
|
|
109
|
+
const serialized = JSON.stringify(responses[0]);
|
|
110
|
+
assert.equal(responses[0]?.result?.isError, true);
|
|
111
|
+
assert.doesNotMatch(serialized, new RegExp(secret));
|
|
112
|
+
assert.match(serialized, /Rebuild preparation failed/);
|
|
113
|
+
});
|
|
114
|
+
test('redacts rebuild apply transport failures', async () => {
|
|
115
|
+
const secret = 'rebuild-apply-secret-nonce';
|
|
116
|
+
const responses = await callMcp([{ name: 'hyv_apply_rebuild', arguments: { task_json: '{}', response_json: '{}', profile_json: '{}', capability_json: JSON.stringify({ payload: secret, signature: secret }) } }], { ...process.env, HYV_MCP_SENSITIVE_INPUT_REDACTION: '1' });
|
|
117
|
+
const serialized = JSON.stringify(responses[0]);
|
|
118
|
+
assert.equal(responses[0]?.result?.isError, true);
|
|
119
|
+
assert.doesNotMatch(serialized, new RegExp(secret));
|
|
120
|
+
assert.match(serialized, /Rebuild application failed/);
|
|
121
|
+
});
|
|
122
|
+
test('redacts capability-bearing transport failures', async () => {
|
|
123
|
+
const secret = 'transport-secret-nonce';
|
|
124
|
+
const responses = await callMcp([{ name: 'hyv_lifecycle_validate_final_approval', arguments: { artifact_json: '{}', capability_json: JSON.stringify({ payload: secret, signature: secret }) } }], { ...process.env, HYV_MCP_SENSITIVE_INPUT_REDACTION: '1' });
|
|
125
|
+
const serialized = JSON.stringify(responses[0]);
|
|
126
|
+
assert.equal(responses[0]?.result?.isError, true);
|
|
127
|
+
assert.doesNotMatch(serialized, new RegExp(secret));
|
|
128
|
+
assert.match(serialized, /Capability validation failed/);
|
|
129
|
+
});
|
|
130
|
+
test('runs registered lifecycle preparation and inspection over stdio', async () => {
|
|
131
|
+
const base = { version: '1', verificationKind: 'standard', passed: true, analysisVersion: '2', rulesetVersion: '3.2.0', preservationMetricVersion: 'legacy-set-v1', preservationScore: 100, sourceHash: '4'.repeat(64), candidateHash: '5'.repeat(64), profileId: 'founder.primary', profileRevisionDigest: '6'.repeat(64), regressionKeys: [] };
|
|
132
|
+
const deterministic = { ...base, artifactFingerprint: createHash('sha256').update(`hyv:deterministic-verification:v1\0${canonicalJson(base)}`).digest('hex') };
|
|
133
|
+
const binding = { rewriteTaskFingerprint: '1'.repeat(64), rewriteResponseFingerprint: '2'.repeat(64), deterministicArtifactFingerprint: deterministic.artifactFingerprint, sourceHash: deterministic.sourceHash, candidateHash: deterministic.candidateHash, profileId: deterministic.profileId, profileRevisionDigest: deterministic.profileRevisionDigest, rulesetVersion: deterministic.rulesetVersion, schemaVersion: '1' };
|
|
134
|
+
const receipt = { version: '1', taskFingerprint: binding.rewriteTaskFingerprint, responseFingerprint: binding.rewriteResponseFingerprint, adapterIds: [], replacementSentenceIds: [1] };
|
|
135
|
+
const preparedResponse = await callMcp([{ name: 'hyv_lifecycle_prepare_semantic', arguments: { deterministic_json: JSON.stringify(deterministic), binding_json: JSON.stringify(binding), receipt_json: JSON.stringify(receipt), policy: 'normal', allowed_violations: ['action_change'] } }], { ...process.env, HYV_MCP_SENSITIVE_INPUT_REDACTION: '' });
|
|
136
|
+
const prepared = toolPayload(preparedResponse[0]);
|
|
137
|
+
assert.equal(prepared.artifact.status, 'needs_semantic_review');
|
|
138
|
+
const inspectedResponse = await callMcp([{ name: 'hyv_lifecycle_inspect', arguments: { artifact_json: JSON.stringify(prepared.artifact) } }]);
|
|
139
|
+
const inspected = toolPayload(inspectedResponse[0]);
|
|
140
|
+
assert.equal(inspected.artifactFingerprint, prepared.artifact.artifactFingerprint);
|
|
141
|
+
assert.doesNotMatch(JSON.stringify(inspected), /sourceHash|candidateHash|payload|signature|nonce/);
|
|
142
|
+
});
|
|
143
|
+
test('runs signed lifecycle and approved-learning replay through registered stdio tools', async () => {
|
|
144
|
+
const root = mkdtempSync(join(tmpdir(), 'holdyourvoice-mcp-approved-'));
|
|
145
|
+
try {
|
|
146
|
+
const source = 'I leverage the answer with useful detail and clear mechanism.';
|
|
147
|
+
const candidate = 'I use the answer with useful detail and clear mechanism.';
|
|
148
|
+
const profile = buildProfile(['I write plainly. I name the work.', 'I keep the mechanism clear. I avoid filler.'], ['leverage']);
|
|
149
|
+
const profileJson = JSON.stringify(profile);
|
|
150
|
+
const rewriteTask = prepareRewriteForMcp(source, profileJson);
|
|
151
|
+
const evaluation = applyRewriteForMcp(JSON.stringify(rewriteTask), JSON.stringify({ version: '1', taskFingerprint: rewriteTask.fingerprint, replacements: [{ sentenceId: 1, text: candidate }] }), profileJson);
|
|
152
|
+
assert.equal(evaluation.status, 'needs_semantic_review');
|
|
153
|
+
const prepared = prepareLifecycleForMcp(JSON.stringify(evaluation.deterministicArtifact), JSON.stringify(evaluation.lifecycleBinding), JSON.stringify(evaluation.receipt), 'normal', []);
|
|
154
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
155
|
+
const now = Math.floor(Date.now() / 1000);
|
|
156
|
+
const trustStore = { version: '1', audience: '@holdyourvoice/hyv', maxCapabilityLifetimeSeconds: 300, keys: [{ issuer: 'test-host', keyId: 'test-key', publicKeySpki: publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'), status: 'active' }] };
|
|
157
|
+
const context = { now: 0, trustStore, authorizedSemanticEvaluatorIds: { normal: ['test-reviewer'], highAssurance: [] }, authorizedHumanFinalizerIds: ['test-human'] };
|
|
158
|
+
const env = { ...installedContextEnvironment(root, context), HYV_HOME: join(root, 'state'), HYV_MCP_SENSITIVE_INPUT_REDACTION: '1' };
|
|
159
|
+
const submittedResponse = await callMcp([{ name: 'hyv_lifecycle_submit_verdict', arguments: { artifact_json: JSON.stringify(prepared.artifact), task_json: JSON.stringify(prepared.task), evaluator_id: 'test-reviewer', verdict_json: JSON.stringify({ approved: true, violations: [] }) } }], env);
|
|
160
|
+
const ready = toolPayload(submittedResponse[0]);
|
|
161
|
+
assert.equal(ready.status, 'ready_for_human_review');
|
|
162
|
+
const rejectedResponse = await callMcp([{ name: 'hyv_lifecycle_finalize', arguments: { artifact_json: JSON.stringify(ready), decision_json: JSON.stringify({ evaluatorId: 'test-human', decision: 'reject' }) } }], { ...env, HYV_MCP_SENSITIVE_INPUT_REDACTION: '' });
|
|
163
|
+
const rejected = toolPayload(rejectedResponse[0]);
|
|
164
|
+
assert.equal(rejected.status, 'needs_escalation');
|
|
165
|
+
assert.equal(rejected.reason, 'human_rejection');
|
|
166
|
+
const binding = ready.binding;
|
|
167
|
+
const claims = { version: '1', purpose: 'hyv.final-approval', issuer: 'test-host', audience: '@holdyourvoice/hyv', subjectArtifactFingerprint: ready.artifactFingerprint, sourceHash: binding.sourceHash, candidateHash: binding.candidateHash, profileId: binding.profileId, profileRevisionDigest: binding.profileRevisionDigest, keyId: 'test-key', issuedAt: now - 1, notBefore: now - 1, expiresAt: now + 120, nonce: 'registered-secret-nonce' };
|
|
168
|
+
const payload = Buffer.from(canonicalJson(claims));
|
|
169
|
+
const capability = canonicalJson({ payload: payload.toString('base64url'), signature: sign(null, payload, privateKey).toString('base64url') });
|
|
170
|
+
const decision = JSON.stringify({ evaluatorId: 'test-human', decision: 'approve' });
|
|
171
|
+
const approvalResponses = await callMcp([
|
|
172
|
+
{ name: 'hyv_lifecycle_validate_final_approval', arguments: { artifact_json: JSON.stringify(ready), capability_json: capability } },
|
|
173
|
+
{ name: 'hyv_lifecycle_finalize', arguments: { artifact_json: JSON.stringify(ready), decision_json: decision, capability_json: capability } },
|
|
174
|
+
], env);
|
|
175
|
+
assert.equal(toolPayload(approvalResponses[0]).ok, true);
|
|
176
|
+
const approved = toolPayload(approvalResponses[1]);
|
|
177
|
+
assert.equal(approved.status, 'approved');
|
|
178
|
+
const learningArguments = { ready_json: JSON.stringify(ready), approved_json: JSON.stringify(approved), original: source, candidate, profile_json: profileJson, decision_json: decision, capability_json: capability };
|
|
179
|
+
const learned = await callMcp([
|
|
180
|
+
{ name: 'hyv_learning_record_approved', arguments: learningArguments },
|
|
181
|
+
{ name: 'hyv_learning_record_approved', arguments: learningArguments },
|
|
182
|
+
], env);
|
|
183
|
+
assert.equal(toolPayload(learned[0]).status, 'recorded');
|
|
184
|
+
assert.equal(toolPayload(learned[1]).status, 'nothing_to_learn');
|
|
185
|
+
const serialized = JSON.stringify({ submittedResponse, rejectedResponse, approvalResponses, learned });
|
|
186
|
+
assert.doesNotMatch(serialized, /registered-secret-nonce|payload|signature|I leverage|I use/);
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
rmSync(root, { recursive: true, force: true });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
test('runs the registered MCP learning lifecycle and rejects invalid profile versions', async () => {
|
|
193
|
+
const root = mkdtempSync(join(tmpdir(), 'holdyourvoice-mcp-lifecycle-'));
|
|
194
|
+
try {
|
|
195
|
+
const env = { ...process.env, HYV_HOME: root };
|
|
196
|
+
const v2Json = JSON.stringify(buildProfile(['I write plainly. I name the work.', 'I keep the mechanism clear. I avoid filler.'], ['leverage']));
|
|
197
|
+
const v3Json = profileV3Json();
|
|
198
|
+
const first = await callMcp([
|
|
199
|
+
{ name: 'hyv_learning_record', arguments: { profile_json: v3Json, instruction: 'Keep the mechanism concrete.', mutation_id: 'm1', authority: 'founder', provenance: 'review', weight: 2, compatibility: 'exact' } },
|
|
200
|
+
{ name: 'hyv_learning_inspect', arguments: { profile_json: v3Json } },
|
|
201
|
+
{ name: 'hyv_learning_record', arguments: { profile_json: v2Json, instruction: 'Keep the evidence named.', mutation_id: 'legacy-1' } },
|
|
202
|
+
{ name: 'hyv_learning_migrate', arguments: { source_profile_json: v2Json, target_profile_json: v3Json, mutation_id: 'migration-1' } },
|
|
203
|
+
{ name: 'hyv_learning_ratify', arguments: { profile_json: v2Json, event_id: 'invalid' } },
|
|
204
|
+
{ name: 'hyv_learning_migrate', arguments: { source_profile_json: v3Json, target_profile_json: v2Json } },
|
|
205
|
+
{ name: 'hyv_learning_record', arguments: { profile_json: v3Json, instruction: 'Invalid schema.', mutation_id: 'm'.repeat(201) } },
|
|
206
|
+
], env);
|
|
207
|
+
const recorded = toolPayload(first[0]);
|
|
208
|
+
assert.equal(recorded.status, 'recorded');
|
|
209
|
+
assert.equal(toolPayload(first[1])[0].eventType, 'instruction');
|
|
210
|
+
assert.equal(toolPayload(first[2]).status, 'recorded');
|
|
211
|
+
assert.equal(toolPayload(first[3]).status, 'recorded');
|
|
212
|
+
assert.equal(first[4]?.result?.isError, true);
|
|
213
|
+
assert.equal(first[5]?.result?.isError, true);
|
|
214
|
+
assert.equal(first[6]?.result?.isError, true);
|
|
215
|
+
const second = await callMcp([
|
|
216
|
+
{ name: 'hyv_learning_record', arguments: { profile_json: v3Json, instruction: 'Different instruction.', mutation_id: 'm1' } },
|
|
217
|
+
{ name: 'hyv_learning_ratify', arguments: { profile_json: v3Json, event_id: recorded.eventId, mutation_id: 'm2', authority: 'founder' } },
|
|
218
|
+
{ name: 'hyv_learning_supersede', arguments: { profile_json: v3Json, event_id: recorded.eventId, mutation_id: 'm3', authority: 'founder' } },
|
|
219
|
+
{ name: 'hyv_learning_clear', arguments: { profile_json: v3Json } },
|
|
220
|
+
{ name: 'hyv_learning_inspect', arguments: { profile_json: v3Json } },
|
|
221
|
+
], env);
|
|
222
|
+
assert.equal(toolPayload(second[0]).status, 'conflict');
|
|
223
|
+
assert.equal(toolPayload(second[1]).status, 'recorded');
|
|
224
|
+
assert.equal(toolPayload(second[2]).status, 'recorded');
|
|
225
|
+
assert.equal(toolPayload(second[3]).cleared, true);
|
|
226
|
+
assert.deepEqual(toolPayload(second[4]), []);
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
rmSync(root, { recursive: true, force: true });
|
|
230
|
+
}
|
|
29
231
|
});
|
|
30
232
|
test('accepts empty text for profile-free hygiene inspection', async () => {
|
|
31
233
|
const server = spawn(process.execPath, [new URL('./cli.js', import.meta.url).pathname, 'mcp'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
@@ -60,7 +262,7 @@ test('gates exact final output through the registered profile-free MCP tool', as
|
|
|
60
262
|
assert.equal(rejected.accepted, false);
|
|
61
263
|
assert.equal('output' in rejected, false);
|
|
62
264
|
});
|
|
63
|
-
test('
|
|
265
|
+
test('keeps registered MCP verification read-only', async () => {
|
|
64
266
|
const root = mkdtempSync(join(tmpdir(), 'holdyourvoice-mcp-server-'));
|
|
65
267
|
try {
|
|
66
268
|
const profile = buildProfile(['I write plainly. I name the work.', 'I keep the mechanism clear. I avoid filler.'], ['leverage']);
|
|
@@ -90,16 +292,13 @@ test('uses default local learning through the registered MCP tools', async () =>
|
|
|
90
292
|
const batch = JSON.parse(responses.find((response) => response.id === 5)?.result?.content?.[0]?.text ?? '{}');
|
|
91
293
|
const malformed = responses.find((response) => response.id === 6)?.result;
|
|
92
294
|
const hygiene = JSON.parse(responses.find((response) => response.id === 7)?.result?.content?.[0]?.text ?? '{}');
|
|
93
|
-
assert.
|
|
295
|
+
assert.doesNotMatch(prompt, /Learned local preferences/);
|
|
94
296
|
assert.equal(contextual.editorial.findings[0].id, 'editorial.social.generic-opener');
|
|
95
297
|
assert.equal(contextual.hygiene.suspiciousCount, 1);
|
|
96
298
|
assert.deepEqual(batch.findings.map((finding) => finding.id), ['batch.repeated-opening', 'batch.repeated-ending']);
|
|
97
299
|
assert.equal(malformed?.isError, true);
|
|
98
300
|
assert.equal(hygiene.suspiciousCount, 1);
|
|
99
|
-
|
|
100
|
-
assert.match(stored, /ai\.leverage/);
|
|
101
|
-
assert.doesNotMatch(stored, /I leverage the answer/);
|
|
102
|
-
assert.doesNotMatch(stored, /I use the answer/);
|
|
301
|
+
assert.equal(existsSync(join(root, 'learning', `${profileFingerprint(profile)}.jsonl`)), false);
|
|
103
302
|
}
|
|
104
303
|
finally {
|
|
105
304
|
rmSync(root, { recursive: true, force: true });
|
package/dist/pipeline.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { canonicalJson } from './canonical-json.js';
|
|
3
|
+
import { HYV_VERSION } from './version.js';
|
|
1
4
|
import { analyzeAiEditor } from './ai-editor.js';
|
|
2
5
|
import { verifyClaims } from './copy-spec.js';
|
|
3
6
|
import { analyzeEditorial } from './editorial-packs.js';
|
|
4
|
-
import { inspectHygiene } from './hygiene.js';
|
|
7
|
+
import { finalOutputCheck, inspectHygiene } from './hygiene.js';
|
|
5
8
|
import { analyzeVoiceDna } from './voice-dna.js';
|
|
6
|
-
import {
|
|
9
|
+
import { legacySetPreservation } from './preservation.js';
|
|
7
10
|
export function analyze(text, profile, brief) {
|
|
8
11
|
const voiceDna = analyzeVoiceDna(text, profile);
|
|
9
|
-
const aiEditor = analyzeAiEditor(text);
|
|
12
|
+
const aiEditor = analyzeAiEditor(text, profile);
|
|
10
13
|
const editorial = brief ? analyzeEditorial(text, brief) : undefined;
|
|
11
14
|
const hygiene = inspectHygiene(text);
|
|
12
15
|
return { version: '2', voiceDna, aiEditor, ...(editorial ? { editorial } : {}), hygiene, passed: voiceDna.passed && aiEditor.passed && (editorial?.passed ?? true) };
|
|
@@ -20,11 +23,24 @@ function formatBriefValue(value) {
|
|
|
20
23
|
function formatFindings(findings) {
|
|
21
24
|
return findings.map((finding) => `- Sentence ${finding.sentence} [${finding.engine}/${finding.id}]: ${formatBriefValue(finding.reason)} Repair: ${formatBriefValue(finding.suggestion)}`);
|
|
22
25
|
}
|
|
23
|
-
export function
|
|
24
|
-
|
|
26
|
+
export function isBlockingFinding(finding) {
|
|
27
|
+
return finding.engine === 'ai_editor' ? finding.appliedPolicy === 'blocking' : finding.severity === 'red';
|
|
28
|
+
}
|
|
29
|
+
export function deriveEditScope(result) {
|
|
30
|
+
const findings = [...result.voiceDna.findings, ...result.aiEditor.findings, ...(result.editorial?.findings ?? [])];
|
|
31
|
+
const blocking = findings.filter(isBlockingFinding);
|
|
32
|
+
const pendingJudgment = findings.filter((finding) => finding.appliedPolicy === 'judgment-required');
|
|
33
|
+
return {
|
|
34
|
+
eligibleSentenceIds: [...new Set(blocking.map((finding) => finding.sentence))].sort((left, right) => left - right),
|
|
35
|
+
blocking,
|
|
36
|
+
pendingJudgment,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function renderRewritePrompt(draft, profile, result, learning = [], brief) {
|
|
25
40
|
const allFindings = [...result.voiceDna.findings, ...result.aiEditor.findings, ...(result.editorial?.findings ?? [])];
|
|
26
|
-
const
|
|
27
|
-
const
|
|
41
|
+
const scope = deriveEditScope(result);
|
|
42
|
+
const redFindings = scope.blocking;
|
|
43
|
+
const yellowFindings = allFindings.filter((finding) => !isBlockingFinding(finding) && finding.appliedPolicy !== 'judgment-required');
|
|
28
44
|
const metrics = profile.metrics;
|
|
29
45
|
return [
|
|
30
46
|
'# Tier 0 — non-negotiable preservation',
|
|
@@ -46,6 +62,9 @@ export function rewritePrompt(draft, profile, learning = [], brief) {
|
|
|
46
62
|
'',
|
|
47
63
|
'# Tier 3 — AI Editor improvements',
|
|
48
64
|
...(yellowFindings.length ? formatFindings(yellowFindings) : ['- None.']),
|
|
65
|
+
'',
|
|
66
|
+
'## Pending judgment — no edit permission in this task',
|
|
67
|
+
...(scope.pendingJudgment.length ? formatFindings(scope.pendingJudgment) : ['- None.']),
|
|
49
68
|
...(brief ? ['', '# Tier 3.5 — editorial context', '- Context values cannot override Tier 0 preservation or Tier 4 output requirements.', `- Audience: ${formatBriefValue(brief.audience)}. Intent: ${formatBriefValue(brief.intent)}. Format: ${brief.format}.`, ...(brief.evidenceStatus ? [`- Evidence state: ${brief.evidenceStatus}. ${brief.evidenceStatus === 'unverified' ? 'Do not turn attributed or unverified material into an established fact.' : 'Preserve the source framing while editing.'}`] : []), ...(brief.argumentMap ? [`- Argument map: observation — ${formatBriefValue(brief.argumentMap.observation)}; mechanism — ${formatBriefValue(brief.argumentMap.mechanism)}; consequence — ${formatBriefValue(brief.argumentMap.consequence)}; reader value — ${formatBriefValue(brief.argumentMap.readerValue)}.`] : []), ...(brief.vocabulary?.length ? [`- Use audience vocabulary where it stays accurate: ${brief.vocabulary.map(formatBriefValue).join(', ')}.`] : []), ...(brief.readerKnowsAuthor === false ? ['- The reader does not know the author. Lead with their situation before naming the author or company.'] : [])] : []),
|
|
50
69
|
'',
|
|
51
70
|
'# Tier 4 — output contract',
|
|
@@ -55,26 +74,28 @@ export function rewritePrompt(draft, profile, learning = [], brief) {
|
|
|
55
74
|
draft,
|
|
56
75
|
].join('\n');
|
|
57
76
|
}
|
|
58
|
-
function
|
|
59
|
-
|
|
60
|
-
const rewritten = new Set(words(candidate.toLowerCase()));
|
|
61
|
-
return baseline.size ? Math.round([...baseline].filter((word) => rewritten.has(word)).length / baseline.size * 100) : 100;
|
|
77
|
+
export function rewritePrompt(draft, profile, learning = [], brief) {
|
|
78
|
+
return renderRewritePrompt(draft, profile, analyze(draft, profile, brief), learning, brief);
|
|
62
79
|
}
|
|
63
|
-
|
|
80
|
+
function compareCandidates(original, candidate, profile, brief) {
|
|
64
81
|
const baseline = analyze(original, profile, brief);
|
|
65
82
|
const checked = analyze(candidate, profile, brief);
|
|
66
83
|
const baselineFindings = [...baseline.voiceDna.findings, ...baseline.aiEditor.findings, ...(baseline.editorial?.findings ?? [])];
|
|
67
84
|
const checkedFindings = [...checked.voiceDna.findings, ...checked.aiEditor.findings, ...(checked.editorial?.findings ?? [])];
|
|
68
85
|
const known = new Set(baselineFindings.map((finding) => `${finding.engine}:${finding.id}:${finding.sentence}`));
|
|
69
86
|
const regressions = checkedFindings.filter((finding) => !known.has(`${finding.engine}:${finding.id}:${finding.sentence}`));
|
|
70
|
-
const preservation =
|
|
87
|
+
const preservation = legacySetPreservation(original, candidate).score;
|
|
88
|
+
return { baseline, checked, regressions, preservation };
|
|
89
|
+
}
|
|
90
|
+
export function verify(original, candidate, profile, brief) {
|
|
91
|
+
const { baseline, checked, regressions, preservation } = compareCandidates(original, candidate, profile, brief);
|
|
71
92
|
return {
|
|
72
93
|
version: '2',
|
|
73
94
|
original: baseline,
|
|
74
95
|
candidate: checked,
|
|
75
96
|
preservationScore: preservation,
|
|
76
97
|
regressions,
|
|
77
|
-
passed: checked.passed && !regressions.some(
|
|
98
|
+
passed: checked.passed && !regressions.some(isBlockingFinding) && preservation >= 70,
|
|
78
99
|
};
|
|
79
100
|
}
|
|
80
101
|
export function verifyWithCopySpec(original, candidate, profile, spec, brief) {
|
|
@@ -82,3 +103,46 @@ export function verifyWithCopySpec(original, candidate, profile, spec, brief) {
|
|
|
82
103
|
const claims = verifyClaims(candidate, spec);
|
|
83
104
|
return { ...verification, claims, passed: verification.passed && claims.passed };
|
|
84
105
|
}
|
|
106
|
+
export function verifyRebuildWithCopySpec(original, candidate, profile, spec, brief) {
|
|
107
|
+
const { baseline, checked, regressions, preservation } = compareCandidates(original, candidate, profile, brief);
|
|
108
|
+
const claims = verifyClaims(candidate, spec);
|
|
109
|
+
const hygiene = inspectHygiene(candidate);
|
|
110
|
+
const finalCheck = finalOutputCheck(candidate);
|
|
111
|
+
return {
|
|
112
|
+
version: '2',
|
|
113
|
+
original: baseline,
|
|
114
|
+
candidate: checked,
|
|
115
|
+
preservationScore: preservation,
|
|
116
|
+
regressions,
|
|
117
|
+
claims,
|
|
118
|
+
passed: checked.passed && !regressions.some(isBlockingFinding) && claims.passed && hygiene.suspiciousCount === 0 && finalCheck.accepted,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function digest(value) { return createHash('sha256').update(value).digest('hex'); }
|
|
122
|
+
function digestCanonical(value) { return digest(canonicalJson(value)); }
|
|
123
|
+
function profileIdentity(profile) {
|
|
124
|
+
if (profile.version === '3')
|
|
125
|
+
return { profileId: profile.id, profileRevisionDigest: profile.revisionDigest };
|
|
126
|
+
const legacy = `legacy-v2:${digestCanonical(profile)}`;
|
|
127
|
+
return { profileId: legacy, profileRevisionDigest: legacy };
|
|
128
|
+
}
|
|
129
|
+
function projectDeterministicVerificationArtifact(source, candidate, profile, verification, copySpec, writingBrief, verificationKind = 'claims' in verification ? 'copy_spec' : 'standard') {
|
|
130
|
+
const identity = profileIdentity(profile);
|
|
131
|
+
const base = {
|
|
132
|
+
version: '1', verificationKind, passed: verification.passed, analysisVersion: verification.candidate.version,
|
|
133
|
+
rulesetVersion: HYV_VERSION, preservationMetricVersion: 'legacy-set-v1', preservationScore: verification.preservationScore,
|
|
134
|
+
sourceHash: digest(source), candidateHash: digest(candidate), ...identity,
|
|
135
|
+
...(copySpec ? { copySpecHash: digestCanonical(copySpec) } : {}), ...(writingBrief ? { writingBriefHash: digestCanonical(writingBrief) } : {}),
|
|
136
|
+
regressionKeys: verification.regressions.map((finding) => `${finding.engine}:${finding.id}:${finding.sentence}`).sort(),
|
|
137
|
+
...('claims' in verification ? { claimFailureKeys: verification.claims.failures.map((failure) => `${failure.id}:${failure.code}`).sort() } : {}),
|
|
138
|
+
};
|
|
139
|
+
return { ...base, artifactFingerprint: digest(`hyv:deterministic-verification:v1\0${canonicalJson(base)}`) };
|
|
140
|
+
}
|
|
141
|
+
export function verifyDeterministically(source, candidate, profile, copySpec, writingBrief) {
|
|
142
|
+
const verification = copySpec ? verifyWithCopySpec(source, candidate, profile, copySpec, writingBrief) : verify(source, candidate, profile, writingBrief);
|
|
143
|
+
return { verification, artifact: projectDeterministicVerificationArtifact(source, candidate, profile, verification, copySpec, writingBrief) };
|
|
144
|
+
}
|
|
145
|
+
export function verifyRebuildDeterministically(source, candidate, profile, copySpec, writingBrief) {
|
|
146
|
+
const verification = verifyRebuildWithCopySpec(source, candidate, profile, copySpec, writingBrief);
|
|
147
|
+
return { verification, artifact: projectDeterministicVerificationArtifact(source, candidate, profile, verification, copySpec, writingBrief, 'rebuild') };
|
|
148
|
+
}
|
package/dist/pipeline.test.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
-
import { analyze, rewritePrompt, verify, verifyWithCopySpec } from './pipeline.js';
|
|
3
|
+
import { analyze, rewritePrompt, verify, verifyDeterministically, verifyWithCopySpec } from './pipeline.js';
|
|
4
4
|
import { parseCopySpec } from './copy-spec.js';
|
|
5
5
|
import { parseWritingBrief } from './editorial-packs.js';
|
|
6
6
|
import { buildProfile } from './voice-dna.js';
|
|
7
|
+
import { comparePreservation } from './preservation.js';
|
|
7
8
|
const profile = buildProfile([
|
|
8
9
|
'I ship clear ideas. The details stay concrete. I explain the mechanism without fuss.',
|
|
9
10
|
'I write short sentences. Then I explain the mechanism. My work stays plain and specific.',
|
|
10
11
|
], ['leverage']);
|
|
11
12
|
test('keeps the two engine scores independent', () => {
|
|
12
13
|
const result = analyze('Firstly, we leverage a holistic strategy.', profile);
|
|
13
|
-
assert.equal(result.aiEditor.passed,
|
|
14
|
+
assert.equal(result.aiEditor.passed, true);
|
|
15
|
+
assert.equal(result.voiceDna.passed, false);
|
|
14
16
|
assert.equal(typeof result.voiceDna.score, 'number');
|
|
15
17
|
});
|
|
16
18
|
test('reports hidden Unicode without changing either engine or the release decision', () => {
|
|
@@ -47,6 +49,28 @@ test('post gate reports a new AI regression', () => {
|
|
|
47
49
|
assert.equal(result.original.aiEditor.passed, true);
|
|
48
50
|
assert.equal(result.candidate.aiEditor.passed, false);
|
|
49
51
|
});
|
|
52
|
+
test('advisory and pending-judgment findings pass while blocking findings fail', () => {
|
|
53
|
+
const advisory = analyze('Firstly, check the invoice.', profile);
|
|
54
|
+
assert.equal(advisory.passed, true);
|
|
55
|
+
const neutralProfile = buildProfile(['I write plainly.', 'I name the mechanism.']);
|
|
56
|
+
const pending = analyze('We leverage the scheduler.', neutralProfile);
|
|
57
|
+
assert.equal(pending.passed, true);
|
|
58
|
+
const blocking = analyze('The scheduler failed — twice.', profile);
|
|
59
|
+
assert.equal(blocking.passed, false);
|
|
60
|
+
});
|
|
61
|
+
test('verify rejects only new policy-blocking regressions', () => {
|
|
62
|
+
const neutralProfile = buildProfile(['I write plainly.', 'I name the mechanism.']);
|
|
63
|
+
assert.equal(verify('The scheduler failed twice.', 'The scheduler failed twice. We leverage logs.', neutralProfile).passed, true);
|
|
64
|
+
assert.equal(verify('The scheduler failed twice.', 'The scheduler failed — twice.', profile).passed, false);
|
|
65
|
+
});
|
|
66
|
+
test('keeps verify pass/fail on the legacy metric while calibration reports both metrics', () => {
|
|
67
|
+
const original = 'alpha bravo alpha charlie durable signal';
|
|
68
|
+
const candidate = 'alpha charlie bravo durable signal';
|
|
69
|
+
const verification = verify(original, candidate, profile);
|
|
70
|
+
const calibration = comparePreservation(original, candidate);
|
|
71
|
+
assert.equal(verification.preservationScore, calibration.legacySet.score);
|
|
72
|
+
assert.notEqual(calibration.legacySet.score / 100, calibration.orderedToken.wordSurvival);
|
|
73
|
+
});
|
|
50
74
|
test('puts all thirteen VoiceDNA elements in the rewrite brief', () => {
|
|
51
75
|
const prompt = rewritePrompt('I ship clear ideas.', profile);
|
|
52
76
|
for (const element of ['Sentence length', 'sentence variation', 'sentence structure', 'rhythm', 'Paragraph length', 'lexical density', 'point of view', 'punctuation', 'case style', 'question rate', 'Openings', 'Vocabulary', 'Transitions']) {
|
|
@@ -136,3 +160,13 @@ test('requires usable CopySpec atoms', () => {
|
|
|
136
160
|
const substring = verifyWithCopySpec('मॉडल INT4 उपलब्ध है।', 'यह नयामॉडल INT4 है।', profile, unicode);
|
|
137
161
|
assert.deepEqual(substring.claims.failures.map((failure) => failure.code), ['missing_immutable_atom']);
|
|
138
162
|
});
|
|
163
|
+
test('projects a stable text-free deterministic verification artifact', () => {
|
|
164
|
+
const original = 'I write clear notes.';
|
|
165
|
+
const candidate = 'I write clear notes.';
|
|
166
|
+
const left = verifyDeterministically(original, candidate, profile).artifact;
|
|
167
|
+
const right = verifyDeterministically(original, candidate, profile).artifact;
|
|
168
|
+
assert.deepEqual(left, right);
|
|
169
|
+
assert.equal(left.passed, true);
|
|
170
|
+
assert.doesNotMatch(JSON.stringify(left), /I write clear notes/);
|
|
171
|
+
assert.notEqual(verifyDeterministically(original, `${candidate} Changed.`, profile).artifact.artifactFingerprint, left.artifactFingerprint);
|
|
172
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { words } from './text.js';
|
|
2
|
+
export const LEGACY_SET_PRESERVATION_VERSION = 'legacy-set-v1';
|
|
3
|
+
export const ORDERED_TOKEN_PRESERVATION_VERSION = 'ordered-token-sequence-v1';
|
|
4
|
+
function donorTokens(text) {
|
|
5
|
+
return (text.match(/[A-Za-z0-9$%#][A-Za-z0-9$%#'’.,-]*/g) ?? [])
|
|
6
|
+
.map((token) => token.replace(/^[.,'’]+|[.,'’]+$/g, '').toLowerCase());
|
|
7
|
+
}
|
|
8
|
+
function lowerBound(values, target) {
|
|
9
|
+
let low = 0;
|
|
10
|
+
let high = values.length;
|
|
11
|
+
while (low < high) {
|
|
12
|
+
const middle = Math.floor((low + high) / 2);
|
|
13
|
+
if (values[middle] < target)
|
|
14
|
+
low = middle + 1;
|
|
15
|
+
else
|
|
16
|
+
high = middle;
|
|
17
|
+
}
|
|
18
|
+
return low;
|
|
19
|
+
}
|
|
20
|
+
function tokenPositions(tokens) {
|
|
21
|
+
const positions = new Map();
|
|
22
|
+
tokens.forEach((token, index) => {
|
|
23
|
+
const indexes = positions.get(token) ?? [];
|
|
24
|
+
indexes.push(index);
|
|
25
|
+
positions.set(token, indexes);
|
|
26
|
+
});
|
|
27
|
+
return positions;
|
|
28
|
+
}
|
|
29
|
+
function longestMatch(left, rightPositions, leftStart, leftEnd, rightStart, rightEnd) {
|
|
30
|
+
let best = [leftStart, rightStart, 0];
|
|
31
|
+
let previous = new Map();
|
|
32
|
+
for (let leftIndex = leftStart; leftIndex < leftEnd; leftIndex += 1) {
|
|
33
|
+
const current = new Map();
|
|
34
|
+
const indexes = rightPositions.get(left[leftIndex]) ?? [];
|
|
35
|
+
for (let position = lowerBound(indexes, rightStart); position < indexes.length && indexes[position] < rightEnd; position += 1) {
|
|
36
|
+
const rightIndex = indexes[position];
|
|
37
|
+
const size = (previous.get(rightIndex - 1) ?? 0) + 1;
|
|
38
|
+
current.set(rightIndex, size);
|
|
39
|
+
const start = [leftIndex - size + 1, rightIndex - size + 1, size];
|
|
40
|
+
if (size > best[2] || (size === best[2] && (start[0] < best[0] || (start[0] === best[0] && start[1] < best[1]))))
|
|
41
|
+
best = start;
|
|
42
|
+
}
|
|
43
|
+
previous = current;
|
|
44
|
+
}
|
|
45
|
+
return best;
|
|
46
|
+
}
|
|
47
|
+
function matchedOrderedTokens(left, right) {
|
|
48
|
+
const rightPositions = tokenPositions(right);
|
|
49
|
+
const pending = [[0, left.length, 0, right.length]];
|
|
50
|
+
let matched = 0;
|
|
51
|
+
while (pending.length) {
|
|
52
|
+
const [leftStart, leftEnd, rightStart, rightEnd] = pending.pop();
|
|
53
|
+
const [matchLeft, matchRight, size] = longestMatch(left, rightPositions, leftStart, leftEnd, rightStart, rightEnd);
|
|
54
|
+
if (!size)
|
|
55
|
+
continue;
|
|
56
|
+
matched += size;
|
|
57
|
+
if (leftStart < matchLeft && rightStart < matchRight)
|
|
58
|
+
pending.push([leftStart, matchLeft, rightStart, matchRight]);
|
|
59
|
+
if (matchLeft + size < leftEnd && matchRight + size < rightEnd)
|
|
60
|
+
pending.push([matchLeft + size, leftEnd, matchRight + size, rightEnd]);
|
|
61
|
+
}
|
|
62
|
+
return matched;
|
|
63
|
+
}
|
|
64
|
+
function roundThree(value) {
|
|
65
|
+
const scaled = value * 1000;
|
|
66
|
+
const lower = Math.floor(scaled);
|
|
67
|
+
const fraction = scaled - lower;
|
|
68
|
+
if (Math.abs(fraction - 0.5) < Number.EPSILON * Math.max(1, Math.abs(scaled)) * 2)
|
|
69
|
+
return (lower + (lower % 2)) / 1000;
|
|
70
|
+
return Math.round(scaled) / 1000;
|
|
71
|
+
}
|
|
72
|
+
export function legacySetPreservation(original, candidate) {
|
|
73
|
+
const baseline = new Set(words(original.toLowerCase()).filter((word) => word.length > 4));
|
|
74
|
+
const rewritten = new Set(words(candidate.toLowerCase()));
|
|
75
|
+
const score = baseline.size ? Math.round([...baseline].filter((word) => rewritten.has(word)).length / baseline.size * 100) : 100;
|
|
76
|
+
return { version: LEGACY_SET_PRESERVATION_VERSION, score };
|
|
77
|
+
}
|
|
78
|
+
export function orderedTokenPreservation(original, candidate) {
|
|
79
|
+
const baseline = donorTokens(original);
|
|
80
|
+
const rewritten = donorTokens(candidate);
|
|
81
|
+
return {
|
|
82
|
+
version: ORDERED_TOKEN_PRESERVATION_VERSION,
|
|
83
|
+
wordSurvival: roundThree(matchedOrderedTokens(baseline, rewritten) / Math.max(baseline.length, 1)),
|
|
84
|
+
lengthRatio: roundThree(rewritten.length / Math.max(baseline.length, 1)),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export function comparePreservation(original, candidate) {
|
|
88
|
+
return { legacySet: legacySetPreservation(original, candidate), orderedToken: orderedTokenPreservation(original, candidate) };
|
|
89
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { comparePreservation, legacySetPreservation, orderedTokenPreservation } from './preservation.js';
|
|
4
|
+
test('reports separately versioned legacy and ordered-token preservation values', () => {
|
|
5
|
+
const report = comparePreservation('alpha bravo alpha charlie', 'alpha charlie bravo');
|
|
6
|
+
assert.deepEqual(report, {
|
|
7
|
+
legacySet: { version: 'legacy-set-v1', score: 100 },
|
|
8
|
+
orderedToken: { version: 'ordered-token-sequence-v1', wordSurvival: 0.5, lengthRatio: 0.75 },
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
test('ports donor token normalization and ordered matching without changing the legacy arithmetic', () => {
|
|
12
|
+
assert.equal(orderedTokenPreservation("Ship, DON'T stop.", "don't ship stop").wordSurvival, 0.667);
|
|
13
|
+
assert.equal(legacySetPreservation('tiny plus durable signal', 'durable signal').score, 100);
|
|
14
|
+
});
|
|
15
|
+
test('defines empty-input denominators explicitly', () => {
|
|
16
|
+
assert.deepEqual(orderedTokenPreservation('', ''), { version: 'ordered-token-sequence-v1', wordSurvival: 0, lengthRatio: 0 });
|
|
17
|
+
assert.deepEqual(legacySetPreservation('', 'new text'), { version: 'legacy-set-v1', score: 100 });
|
|
18
|
+
});
|
|
19
|
+
test('matches the donor metric three-decimal half-even rounding', () => {
|
|
20
|
+
const original = Array.from({ length: 16 }, (_, index) => `token${index}`).join(' ');
|
|
21
|
+
assert.equal(orderedTokenPreservation(original, 'token0').wordSurvival, 0.062);
|
|
22
|
+
});
|