@holdyourvoice/hyv 3.3.0 → 3.3.2
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 +84 -13
- package/dist/cli.js +92 -6
- package/dist/cli.test.js +54 -2
- package/dist/editorial-packs.js +35 -1
- package/dist/fact-linter.js +191 -0
- package/dist/fact-linter.test.js +85 -0
- package/dist/hidden-text.js +77 -0
- package/dist/hidden-text.test.js +26 -0
- package/dist/hygiene.js +8 -1
- package/dist/hygiene.test.js +15 -5
- package/dist/mcp-tools.js +13 -3
- package/dist/mcp-tools.test.js +15 -2
- package/dist/mcp.js +22 -2
- package/dist/mcp.test.js +6 -3
- package/dist/pipeline.js +33 -3
- package/dist/pipeline.test.js +46 -1
- package/dist/provenance-status.js +15 -0
- package/dist/provenance-status.test.js +22 -0
- package/dist/rebuild-task.js +37 -7
- package/dist/rebuild-task.test.js +28 -1
- package/dist/recomposition.js +97 -0
- package/dist/recomposition.test.js +34 -0
- package/dist/rewrite-task.js +11 -5
- package/dist/rewrite-task.test.js +15 -0
- package/dist/text-provenance.feature.test.js +45 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/mcp.test.js
CHANGED
|
@@ -64,8 +64,8 @@ test('serves local Claude tools over stdio', async () => {
|
|
|
64
64
|
assert.equal(code, 0);
|
|
65
65
|
const responses = stdout.trim().split('\n').map((line) => JSON.parse(line));
|
|
66
66
|
const tools = responses.find((response) => response.id === 2)?.result?.tools;
|
|
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));
|
|
67
|
+
assert.deepEqual(tools?.map((tool) => tool.name), ['hyv_build_profile', 'hyv_analyze', 'hyv_hygiene', 'hyv_inspect_hidden_text', 'hyv_apply_hidden_text_policy', '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_apply_hidden_text_policy', 'hyv_learning_record', 'hyv_learning_ratify', 'hyv_learning_supersede', 'hyv_learning_migrate', 'hyv_learning_clear'].includes(tool.name)).every((tool) => tool.annotations?.readOnlyHint));
|
|
69
69
|
assert.equal(tools?.find((tool) => tool.name === 'hyv_verify')?.annotations?.readOnlyHint, true);
|
|
70
70
|
assert.equal(tools?.find((tool) => tool.name === 'hyv_verify_copy_spec')?.annotations?.readOnlyHint, true);
|
|
71
71
|
assert.equal(tools?.some((tool) => tool.name === 'hyv_lifecycle_validate_final_approval'), false);
|
|
@@ -74,6 +74,8 @@ test('serves local Claude tools over stdio', async () => {
|
|
|
74
74
|
assert.equal(tools?.some((tool) => tool.name === 'hyv_apply_rebuild'), false);
|
|
75
75
|
assert.equal('capability_json' in (tools?.find((tool) => tool.name === 'hyv_lifecycle_finalize')?.inputSchema?.properties ?? {}), false);
|
|
76
76
|
assert.equal(tools?.find((tool) => tool.name === 'hyv_learning_inspect')?.annotations?.readOnlyHint, true);
|
|
77
|
+
assert.equal(tools?.find((tool) => tool.name === 'hyv_inspect_hidden_text')?.annotations?.readOnlyHint, true);
|
|
78
|
+
assert.equal(tools?.find((tool) => tool.name === 'hyv_apply_hidden_text_policy')?.annotations?.readOnlyHint, false);
|
|
77
79
|
assert.equal(tools?.find((tool) => tool.name === 'hyv_learning_clear')?.annotations?.readOnlyHint, false);
|
|
78
80
|
assert.deepEqual(tools?.filter((tool) => tool.name.startsWith('hyv_learning_')).map((tool) => [tool.name, tool.annotations?.readOnlyHint, tool.annotations?.destructiveHint]), [
|
|
79
81
|
['hyv_learning_inspect', true, undefined], ['hyv_learning_record', false, false], ['hyv_learning_ratify', false, false],
|
|
@@ -95,11 +97,12 @@ test('registers capability tools only with host redaction attestation', async ()
|
|
|
95
97
|
assert.equal(stderr, '');
|
|
96
98
|
const response = stdout.trim().split('\n').map((line) => JSON.parse(line)).find((item) => item.id === 2);
|
|
97
99
|
const names = response.result.tools.map((tool) => tool.name);
|
|
98
|
-
assert.equal(names.length,
|
|
100
|
+
assert.equal(names.length, 30);
|
|
99
101
|
assert.ok(names.includes('hyv_lifecycle_validate_final_approval'));
|
|
100
102
|
assert.ok(names.includes('hyv_learning_record_approved'));
|
|
101
103
|
assert.ok(names.includes('hyv_prepare_rebuild'));
|
|
102
104
|
assert.ok(names.includes('hyv_apply_rebuild'));
|
|
105
|
+
assert.ok(names.includes('hyv_rebuild_writer_request'));
|
|
103
106
|
const finalize = response.result.tools.find((tool) => tool.name === 'hyv_lifecycle_finalize');
|
|
104
107
|
assert.equal('capability_json' in finalize.inputSchema.properties, true);
|
|
105
108
|
});
|
package/dist/pipeline.js
CHANGED
|
@@ -7,6 +7,8 @@ import { analyzeEditorial } from './editorial-packs.js';
|
|
|
7
7
|
import { finalOutputCheck, inspectHygiene } from './hygiene.js';
|
|
8
8
|
import { analyzeVoiceDna } from './voice-dna.js';
|
|
9
9
|
import { legacySetPreservation } from './preservation.js';
|
|
10
|
+
import { lintFacts } from './fact-linter.js';
|
|
11
|
+
import { sentences } from './text.js';
|
|
10
12
|
export function analyze(text, profile, brief) {
|
|
11
13
|
const voiceDna = analyzeVoiceDna(text, profile);
|
|
12
14
|
const aiEditor = analyzeAiEditor(text, profile);
|
|
@@ -87,15 +89,40 @@ function compareCandidates(original, candidate, profile, brief) {
|
|
|
87
89
|
const preservation = legacySetPreservation(original, candidate).score;
|
|
88
90
|
return { baseline, checked, regressions, preservation };
|
|
89
91
|
}
|
|
92
|
+
function verifyRequiredFacts(candidate, brief) {
|
|
93
|
+
if (!brief?.requiredFacts?.length)
|
|
94
|
+
return undefined;
|
|
95
|
+
const result = verifyClaims(candidate, {
|
|
96
|
+
version: '1', audience: brief.audience, intent: brief.intent, channel: brief.format,
|
|
97
|
+
claims: brief.requiredFacts.map((fact) => ({ ...fact, evidence: 'WritingBrief required fact.' })),
|
|
98
|
+
});
|
|
99
|
+
const draftSentences = sentences(candidate);
|
|
100
|
+
const reversed = brief.requiredFacts.filter((fact) => {
|
|
101
|
+
const terms = fact.atoms?.length ? fact.atoms : [fact.text];
|
|
102
|
+
return terms.some((term) => {
|
|
103
|
+
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
104
|
+
const quotedDenial = new RegExp(`${escaped}(?:["']|\\s)*(?:is|was|are|were)?\\s*(?:not|false|untrue|incorrect)`, 'i');
|
|
105
|
+
return quotedDenial.test(candidate) || draftSentences.some((sentence, index) => sentence.text.toLowerCase().includes(term.toLowerCase()) && (/\b(?:not|false|untrue|incorrect)\b/i.test(sentence.text) || /^(?:that|this) (?:statement|claim|fact|assertion|point) (?:is|was) (?:not|false|untrue|incorrect)\b/i.test(draftSentences[index + 1]?.text.trim() ?? '')));
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
if (!reversed.length)
|
|
109
|
+
return result;
|
|
110
|
+
return { ...result, passed: false, failures: [...result.failures, ...reversed.map((fact) => ({ id: fact.id, code: 'missing_immutable_claim', message: `Required fact ${fact.id} is negated or denied.`, evidence: 'WritingBrief required fact.' }))] };
|
|
111
|
+
}
|
|
90
112
|
export function verify(original, candidate, profile, brief) {
|
|
91
113
|
const { baseline, checked, regressions, preservation } = compareCandidates(original, candidate, profile, brief);
|
|
114
|
+
const finalOutput = finalOutputCheck(candidate);
|
|
115
|
+
const factLint = brief?.factSources?.length ? lintFacts({ sources: brief.factSources, draft: candidate, metadata: brief.factMetadata }) : undefined;
|
|
116
|
+
const requiredFacts = verifyRequiredFacts(candidate, brief);
|
|
92
117
|
return {
|
|
93
118
|
version: '2',
|
|
94
119
|
original: baseline,
|
|
95
120
|
candidate: checked,
|
|
96
121
|
preservationScore: preservation,
|
|
97
122
|
regressions,
|
|
98
|
-
|
|
123
|
+
finalOutput,
|
|
124
|
+
...(factLint ? { factLint } : {}), ...(requiredFacts ? { requiredFacts } : {}),
|
|
125
|
+
passed: checked.passed && !regressions.some(isBlockingFinding) && preservation >= 70 && finalOutput.accepted && !factLint?.findings.some((finding) => finding.severity === 'error') && (requiredFacts?.passed ?? true),
|
|
99
126
|
};
|
|
100
127
|
}
|
|
101
128
|
export function verifyWithCopySpec(original, candidate, profile, spec, brief) {
|
|
@@ -106,8 +133,9 @@ export function verifyWithCopySpec(original, candidate, profile, spec, brief) {
|
|
|
106
133
|
export function verifyRebuildWithCopySpec(original, candidate, profile, spec, brief) {
|
|
107
134
|
const { baseline, checked, regressions, preservation } = compareCandidates(original, candidate, profile, brief);
|
|
108
135
|
const claims = verifyClaims(candidate, spec);
|
|
109
|
-
const hygiene = inspectHygiene(candidate);
|
|
110
136
|
const finalCheck = finalOutputCheck(candidate);
|
|
137
|
+
const factLint = brief?.factSources?.length ? lintFacts({ sources: brief.factSources, draft: candidate, metadata: brief.factMetadata }) : undefined;
|
|
138
|
+
const requiredFacts = verifyRequiredFacts(candidate, brief);
|
|
111
139
|
return {
|
|
112
140
|
version: '2',
|
|
113
141
|
original: baseline,
|
|
@@ -115,7 +143,9 @@ export function verifyRebuildWithCopySpec(original, candidate, profile, spec, br
|
|
|
115
143
|
preservationScore: preservation,
|
|
116
144
|
regressions,
|
|
117
145
|
claims,
|
|
118
|
-
|
|
146
|
+
finalOutput: finalCheck,
|
|
147
|
+
...(factLint ? { factLint } : {}), ...(requiredFacts ? { requiredFacts } : {}),
|
|
148
|
+
passed: checked.passed && !regressions.some(isBlockingFinding) && claims.passed && finalCheck.accepted && !factLint?.findings.some((finding) => finding.severity === 'error') && (requiredFacts?.passed ?? true),
|
|
119
149
|
};
|
|
120
150
|
}
|
|
121
151
|
function digest(value) { return createHash('sha256').update(value).digest('hex'); }
|
package/dist/pipeline.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
-
import { analyze, rewritePrompt, verify, verifyDeterministically, verifyWithCopySpec } from './pipeline.js';
|
|
3
|
+
import { analyze, rewritePrompt, verify, verifyDeterministically, verifyRebuildWithCopySpec, 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';
|
|
@@ -32,6 +32,45 @@ test('keeps the existing VoiceDNA and AI Editor reports unchanged when no Writin
|
|
|
32
32
|
assert.deepEqual(contextual.voiceDna, baseline.voiceDna);
|
|
33
33
|
assert.deepEqual(contextual.aiEditor, baseline.aiEditor);
|
|
34
34
|
});
|
|
35
|
+
test('runs fact lint automatically when a WritingBrief supplies valid local sources', () => {
|
|
36
|
+
const brief = parseWritingBrief({ version: '1', audience: 'operators', intent: 'explain', format: 'general', factSources: [{ id: 'release', text: 'Atlas launches on 14 August 2026.' }] });
|
|
37
|
+
const result = verify('Atlas launches on 14 August 2026.', 'Atlas launches on 15 August 2026.', profile, brief);
|
|
38
|
+
assert.equal(result.factLint?.findings[0]?.kind, 'date_drift');
|
|
39
|
+
assert.equal(result.passed, false);
|
|
40
|
+
});
|
|
41
|
+
test('blocks a final draft that drops a required source-backed fact', () => {
|
|
42
|
+
const brief = parseWritingBrief({ version: '1', audience: 'founders', intent: 'write a post', format: 'social', factSources: [{ id: 'bio', text: 'Shashank is a LinkedIn Top Voice.' }], requiredFacts: [{ id: 'linkedin-top-voice', text: 'Shashank is a LinkedIn Top Voice.' }] });
|
|
43
|
+
const missing = verify('Shashank is a LinkedIn Top Voice.', 'Shashank writes about AI systems.', profile, brief);
|
|
44
|
+
assert.equal(missing.requiredFacts?.failures[0]?.code, 'missing_immutable_claim');
|
|
45
|
+
assert.equal(missing.passed, false);
|
|
46
|
+
const retained = verify('Shashank is a LinkedIn Top Voice.', 'Shashank is a LinkedIn Top Voice. Shashank writes about AI systems.', profile, brief);
|
|
47
|
+
assert.equal(retained.requiredFacts?.passed, true);
|
|
48
|
+
});
|
|
49
|
+
test('requires source provenance and rejects negated required facts', () => {
|
|
50
|
+
assert.throws(() => parseWritingBrief({ version: '1', audience: 'founders', intent: 'write a post', format: 'social', requiredFacts: [{ id: 'unsupported', text: 'Mars has two moons.' }] }), /WritingBrief/);
|
|
51
|
+
assert.throws(() => parseWritingBrief({ version: '1', audience: 'founders', intent: 'write a post', format: 'social', factSources: [{ id: 'denial', text: 'It is false that Shashank is a LinkedIn Top Voice.' }], requiredFacts: [{ id: 'linkedin-top-voice', text: 'Shashank is a LinkedIn Top Voice.' }] }), /WritingBrief/);
|
|
52
|
+
assert.throws(() => parseWritingBrief({ version: '1', audience: 'founders', intent: 'write a post', format: 'social', factSources: [{ id: 'cross-sentence-denial', text: 'Shashank is a LinkedIn Top Voice. That statement is false.' }], requiredFacts: [{ id: 'linkedin-top-voice', text: 'Shashank is a LinkedIn Top Voice.' }] }), /WritingBrief/);
|
|
53
|
+
const brief = parseWritingBrief({ version: '1', audience: 'founders', intent: 'write a post', format: 'social', factSources: [{ id: 'bio', text: 'Shashank is a LinkedIn Top Voice.' }], requiredFacts: [{ id: 'linkedin-top-voice', text: 'Shashank is a LinkedIn Top Voice.' }] });
|
|
54
|
+
const negated = verify('Shashank is a LinkedIn Top Voice.', 'Shashank is not a LinkedIn Top Voice.', profile, brief);
|
|
55
|
+
assert.equal(negated.requiredFacts?.passed, false);
|
|
56
|
+
const denied = verify('Shashank is a LinkedIn Top Voice.', 'The claim "Shashank is a LinkedIn Top Voice." is false.', profile, brief);
|
|
57
|
+
assert.equal(denied.requiredFacts?.passed, false);
|
|
58
|
+
assert.match(denied.requiredFacts?.failures.at(-1)?.message ?? '', /negated or denied/);
|
|
59
|
+
const crossSentenceDenial = verify('Shashank is a LinkedIn Top Voice.', 'Shashank is a LinkedIn Top Voice. That statement is false.', profile, brief);
|
|
60
|
+
assert.equal(crossSentenceDenial.requiredFacts?.passed, false);
|
|
61
|
+
const affirmed = verify('Shashank is a LinkedIn Top Voice.', 'This is not a controversial claim. Shashank is a LinkedIn Top Voice.', profile, brief);
|
|
62
|
+
assert.equal(affirmed.requiredFacts?.passed, true);
|
|
63
|
+
const atomBrief = parseWritingBrief({ version: '1', audience: 'founders', intent: 'write a post', format: 'social', factSources: [{ id: 'model', text: 'Kimi K2.6 uses INT4 weights. The payload is roughly 600 GB.' }], requiredFacts: [{ id: 'model-weights', text: 'Kimi K2.6 has roughly 600 GB of INT4 weights.', atoms: ['Kimi K2.6 uses INT4 weights', 'payload is roughly 600 GB'] }] });
|
|
64
|
+
const atomDenial = verify('Kimi K2.6 has roughly 600 GB of INT4 weights.', 'Kimi K2.6 uses INT4 weights, which is false. The payload is roughly 600 GB.', profile, atomBrief);
|
|
65
|
+
assert.equal(atomDenial.requiredFacts?.passed, false);
|
|
66
|
+
});
|
|
67
|
+
test('runs fact lint during source-backed rebuild verification', () => {
|
|
68
|
+
const brief = parseWritingBrief({ version: '1', audience: 'operators', intent: 'explain', format: 'general', factSources: [{ id: 'release', text: 'Atlas launches on 14 August 2026.' }] });
|
|
69
|
+
const spec = parseCopySpec({ version: '1', audience: 'operators', intent: 'explain', channel: 'email', claims: [{ id: 'date', text: 'Atlas launches on 15 August 2026.', evidence: 'release' }] });
|
|
70
|
+
const result = verifyRebuildWithCopySpec('Atlas launches on 14 August 2026.', 'Atlas launches on 15 August 2026.', profile, spec, brief);
|
|
71
|
+
assert.equal(result.factLint?.findings[0]?.kind, 'date_drift');
|
|
72
|
+
assert.equal(result.passed, false);
|
|
73
|
+
});
|
|
35
74
|
test('builds all thirteen VoiceDNA measurements', () => {
|
|
36
75
|
assert.deepEqual(Object.keys(profile.metrics), ['sentenceLength', 'sentenceVariation', 'sentenceStructure', 'rhythm', 'paragraphLength', 'openingMoves', 'vocabulary', 'lexicalDensity', 'pointOfView', 'punctuation', 'caseStyle', 'questionRate', 'transitions']);
|
|
37
76
|
});
|
|
@@ -49,6 +88,12 @@ test('post gate reports a new AI regression', () => {
|
|
|
49
88
|
assert.equal(result.original.aiEditor.passed, true);
|
|
50
89
|
assert.equal(result.candidate.aiEditor.passed, false);
|
|
51
90
|
});
|
|
91
|
+
test('verification applies the final-output gate by default', () => {
|
|
92
|
+
const result = verify('I ship clear ideas.', 'I ship clear ideas.\u200B', profile);
|
|
93
|
+
assert.equal(result.passed, false);
|
|
94
|
+
assert.equal(result.finalOutput.accepted, false);
|
|
95
|
+
assert.equal('output' in result.finalOutput, false);
|
|
96
|
+
});
|
|
52
97
|
test('advisory and pending-judgment findings pass while blocking findings fail', () => {
|
|
53
98
|
const advisory = analyze('Firstly, check the invoice.', profile);
|
|
54
99
|
assert.equal(advisory.passed, true);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { canonicalJson } from './canonical-json.js';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
function fingerprint(value) { return createHash('sha256').update(canonicalJson(value)).digest('hex'); }
|
|
4
|
+
export function writerRequestForRebuild(task) {
|
|
5
|
+
return {
|
|
6
|
+
version: '1', taskFingerprint: task.fingerprint, prompt: task.prompt,
|
|
7
|
+
copySpecFingerprint: fingerprint(task.copySpec),
|
|
8
|
+
...(task.recompositionPolicy ? { recompositionPolicyFingerprint: fingerprint(task.recompositionPolicy) } : {}),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export function provenanceStatusForRebuild(task) {
|
|
12
|
+
return task.recompositionPolicy
|
|
13
|
+
? { version: '1', state: 'unknown', reason: 'private_or_unavailable_verifier' }
|
|
14
|
+
: { version: '1', state: 'not_configured' };
|
|
15
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { provenanceStatusForRebuild, writerRequestForRebuild } from './provenance-status.js';
|
|
4
|
+
const policy = { version: '1', mode: 'meaning-first', lexicalResidual: { ngramSize: 5, maxSharedNgramFraction: 0.1, maxLongestSharedRunTokens: 8 }, acknowledgement: 'Measures shared wording only; does not detect or prove removal of a watermark.' };
|
|
5
|
+
const task = {
|
|
6
|
+
version: '1', fingerprint: 'a'.repeat(64), draft: 'private source phrase that must never leave the task', prompt: 'structured CopySpec-only prompt',
|
|
7
|
+
copySpec: { version: '1', audience: 'operators', intent: 'explain', channel: 'email', claims: [{ id: 'one', text: 'The date is fixed.', evidence: 'calendar' }] },
|
|
8
|
+
recommendationFingerprint: 'b'.repeat(64), authorizationFingerprint: 'c'.repeat(64), profileId: 'profile', profileRevisionDigest: 'd'.repeat(64), recompositionPolicy: policy,
|
|
9
|
+
};
|
|
10
|
+
test('writer request omits source, authorization, and profile body while retaining stable bindings', () => {
|
|
11
|
+
const request = writerRequestForRebuild(task);
|
|
12
|
+
const serialized = JSON.stringify(request);
|
|
13
|
+
assert.equal(request.taskFingerprint, task.fingerprint);
|
|
14
|
+
assert.match(request.prompt, /structured CopySpec-only prompt/);
|
|
15
|
+
assert.doesNotMatch(serialized, /private source phrase|authorizationFingerprint|profileRevisionDigest|draft/);
|
|
16
|
+
assert.match(request.copySpecFingerprint, /^[a-f0-9]{64}$/);
|
|
17
|
+
assert.match(request.recompositionPolicyFingerprint ?? '', /^[a-f0-9]{64}$/);
|
|
18
|
+
});
|
|
19
|
+
test('private-provider status is unknown and ordinary rebuild has no configured verifier', () => {
|
|
20
|
+
assert.deepEqual(provenanceStatusForRebuild(task), { version: '1', state: 'unknown', reason: 'private_or_unavailable_verifier' });
|
|
21
|
+
assert.deepEqual(provenanceStatusForRebuild({ ...task, recompositionPolicy: undefined }), { version: '1', state: 'not_configured' });
|
|
22
|
+
});
|
package/dist/rebuild-task.js
CHANGED
|
@@ -5,8 +5,11 @@ import { parseWritingBrief } from './editorial-packs.js';
|
|
|
5
5
|
import { verifyApprovalCapability } from './approval-capability.js';
|
|
6
6
|
import { fingerprintPreEditReduction } from './judgment-task.js';
|
|
7
7
|
import { verifyRebuildDeterministically } from './pipeline.js';
|
|
8
|
+
import { finalOutputCheck } from './hygiene.js';
|
|
8
9
|
import { sentences } from './text.js';
|
|
9
10
|
import { HYV_VERSION } from './version.js';
|
|
11
|
+
import { buildRecompositionBrief, measureLexicalResidual, parseRecompositionPolicy } from './recomposition.js';
|
|
12
|
+
import { provenanceStatusForRebuild, writerRequestForRebuild } from './provenance-status.js';
|
|
10
13
|
const MAX_RESPONSE_BYTES = 100_000;
|
|
11
14
|
const MAX_CANDIDATE_CHARACTERS = 100_000;
|
|
12
15
|
function fingerprint(value) {
|
|
@@ -66,7 +69,9 @@ function parseRebuildResponse(value) {
|
|
|
66
69
|
}
|
|
67
70
|
return response;
|
|
68
71
|
}
|
|
69
|
-
function renderRebuildPrompt(draft, copySpec, writingBrief) {
|
|
72
|
+
function renderRebuildPrompt(draft, copySpec, writingBrief, recompositionPolicy) {
|
|
73
|
+
if (recompositionPolicy)
|
|
74
|
+
return buildRecompositionBrief(copySpec, writingBrief);
|
|
70
75
|
return [
|
|
71
76
|
'# Rebuild contract',
|
|
72
77
|
'Return a whole-document candidate. Do not emit sentence replacements or range operations.',
|
|
@@ -107,7 +112,7 @@ function verifyRebuildAuthorization(draft, profile, recommendationFingerprint, c
|
|
|
107
112
|
throw new Error('Rebuild authorization is invalid.');
|
|
108
113
|
return authorized;
|
|
109
114
|
}
|
|
110
|
-
export function prepareRebuildTask(draft, profile, reduction, copySpec, capability, trustStore, now, writingBrief) {
|
|
115
|
+
export function prepareRebuildTask(draft, profile, reduction, copySpec, capability, trustStore, now, writingBrief, recompositionPolicy) {
|
|
111
116
|
if (reduction.decision !== 'REBUILD')
|
|
112
117
|
throw new Error('Rebuild requires an upstream REBUILD recommendation.');
|
|
113
118
|
if (fingerprintPreEditReduction(reduction) !== reduction.recommendationFingerprint || reduction.recommendationFingerprint.length !== 64) {
|
|
@@ -118,16 +123,19 @@ export function prepareRebuildTask(draft, profile, reduction, copySpec, capabili
|
|
|
118
123
|
const authorized = verifyRebuildAuthorization(draft, profile, reduction.recommendationFingerprint, capability, trustStore, now);
|
|
119
124
|
if (writingBrief)
|
|
120
125
|
parseWritingBrief(writingBrief);
|
|
126
|
+
if (recompositionPolicy)
|
|
127
|
+
parseRecompositionPolicy(recompositionPolicy);
|
|
121
128
|
const taskBase = {
|
|
122
129
|
version: '1',
|
|
123
130
|
draft,
|
|
124
|
-
prompt: renderRebuildPrompt(draft, spec, writingBrief),
|
|
131
|
+
prompt: renderRebuildPrompt(draft, spec, writingBrief, recompositionPolicy),
|
|
125
132
|
copySpec: spec,
|
|
126
133
|
recommendationFingerprint: reduction.recommendationFingerprint,
|
|
127
134
|
authorizationFingerprint: authorized.capabilityFingerprint,
|
|
128
135
|
profileId: identity.profileId,
|
|
129
136
|
profileRevisionDigest: identity.profileRevisionDigest,
|
|
130
137
|
...(writingBrief ? { writingBrief } : {}),
|
|
138
|
+
...(recompositionPolicy ? { recompositionPolicy } : {}),
|
|
131
139
|
};
|
|
132
140
|
return { ...taskBase, fingerprint: fingerprint(taskBase) };
|
|
133
141
|
}
|
|
@@ -143,6 +151,8 @@ export function parseRebuildTask(value) {
|
|
|
143
151
|
parseCopySpec(task.copySpec);
|
|
144
152
|
if (task.writingBrief !== undefined)
|
|
145
153
|
parseWritingBrief(task.writingBrief);
|
|
154
|
+
if (task.recompositionPolicy !== undefined)
|
|
155
|
+
parseRecompositionPolicy(task.recompositionPolicy);
|
|
146
156
|
const { fingerprint: suppliedFingerprint, ...base } = task;
|
|
147
157
|
if (fingerprint(base) !== suppliedFingerprint)
|
|
148
158
|
throw new Error('Rebuild task fingerprint does not match its contents.');
|
|
@@ -196,18 +206,38 @@ export function evaluateRebuildResponse(task, raw, profile, capability, trustSto
|
|
|
196
206
|
const applied = applyRebuildResponse(task, raw);
|
|
197
207
|
if (applied.status !== 'accepted' || !applied.candidate)
|
|
198
208
|
return applied;
|
|
199
|
-
const
|
|
209
|
+
const output = finalOutputCheck(applied.candidate);
|
|
210
|
+
const candidate = output.accepted ? output.output : applied.candidate;
|
|
211
|
+
const checked = verifyRebuildDeterministically(task.draft, candidate, profile, task.copySpec, task.writingBrief);
|
|
212
|
+
const verification = { ...checked.verification, finalOutput: output };
|
|
213
|
+
const deterministicArtifact = checked.artifact;
|
|
200
214
|
const receipt = {
|
|
201
215
|
...applied.receipt,
|
|
202
216
|
preservationBypass: true,
|
|
203
217
|
authorizationFingerprint: authorized.capabilityFingerprint,
|
|
204
218
|
preservationScore: verification.preservationScore,
|
|
219
|
+
...(task.recompositionPolicy ? { lexicalResidual: measureLexicalResidual(task.draft, candidate, task.copySpec, task.recompositionPolicy) } : {}),
|
|
220
|
+
provenanceStatus: provenanceStatusForRebuild(task),
|
|
205
221
|
};
|
|
206
|
-
if (!verification.passed)
|
|
207
|
-
|
|
222
|
+
if (!verification.passed) {
|
|
223
|
+
const { candidate: _candidate, ...withheld } = applied;
|
|
224
|
+
return { ...withheld, receipt, status: 'needs_escalation', verification, deterministicArtifact };
|
|
225
|
+
}
|
|
226
|
+
if (receipt.lexicalResidual && !receipt.lexicalResidual.passed) {
|
|
227
|
+
const { candidate: _candidate, ...withheld } = applied;
|
|
228
|
+
return {
|
|
229
|
+
...withheld,
|
|
230
|
+
receipt,
|
|
231
|
+
failures: [failure('lexical_residual_exceeds_policy', 'Candidate exceeds the configured lexical-residual policy.')],
|
|
232
|
+
status: 'needs_escalation',
|
|
233
|
+
verification,
|
|
234
|
+
deterministicArtifact,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
208
237
|
const lifecycleBinding = createRebuildLifecycleBinding(task, receipt, deterministicArtifact);
|
|
209
|
-
return { ...applied, receipt, status: 'needs_semantic_review', verification, deterministicArtifact, lifecycleBinding };
|
|
238
|
+
return { ...applied, candidate, receipt, status: 'needs_semantic_review', verification, deterministicArtifact, lifecycleBinding };
|
|
210
239
|
}
|
|
240
|
+
export { writerRequestForRebuild };
|
|
211
241
|
export function createRebuildLifecycleBinding(task, receipt, deterministic) {
|
|
212
242
|
if (!deterministic.passed || receipt.taskFingerprint !== task.fingerprint || receipt.mode !== 'REBUILD' || deterministic.verificationKind !== 'rebuild') {
|
|
213
243
|
throw new Error('Lifecycle binding requires a passed rebuild artifact for this rebuild task.');
|
|
@@ -22,6 +22,11 @@ const copySpec = {
|
|
|
22
22
|
channel: 'email',
|
|
23
23
|
claims: [{ id: 'launch-date', text: 'The launch is on 14 August.', evidence: 'Release calendar, 7 August.' }],
|
|
24
24
|
};
|
|
25
|
+
const recompositionPolicy = {
|
|
26
|
+
version: '1', mode: 'meaning-first',
|
|
27
|
+
lexicalResidual: { ngramSize: 5, maxSharedNgramFraction: 0, maxLongestSharedRunTokens: 4 },
|
|
28
|
+
acknowledgement: 'Measures shared wording only; does not detect or prove removal of a watermark.',
|
|
29
|
+
};
|
|
25
30
|
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
26
31
|
const trustStore = {
|
|
27
32
|
version: '1',
|
|
@@ -129,6 +134,13 @@ test('authorized rebuild allows low lexical survival while claims and hygiene st
|
|
|
129
134
|
assert.equal(missingClaim.status, 'needs_escalation');
|
|
130
135
|
const hygiene = evaluate(task, { version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint, candidate: `${rebuilt}\u200B` }, reduction);
|
|
131
136
|
assert.equal(hygiene.status, 'needs_escalation');
|
|
137
|
+
assert.equal(hygiene.candidate, undefined);
|
|
138
|
+
assert.equal(hygiene.verification?.finalOutput.accepted, false);
|
|
139
|
+
const cleaned = evaluate(task, { version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint, candidate: `${rebuilt}\u0007` }, reduction);
|
|
140
|
+
assert.equal(cleaned.status, 'needs_semantic_review');
|
|
141
|
+
assert.equal(cleaned.candidate, rebuilt);
|
|
142
|
+
assert.equal(cleaned.verification?.finalOutput.changed, true);
|
|
143
|
+
assert.equal(cleaned.deterministicArtifact?.candidateHash, createHash('sha256').update(rebuilt).digest('hex'));
|
|
132
144
|
});
|
|
133
145
|
test('rebuild disagreement cannot record accepted learning', () => {
|
|
134
146
|
const reduction = rebuildRecommendation();
|
|
@@ -162,7 +174,7 @@ test('CLI and MCP rebuild helpers share fingerprints', () => {
|
|
|
162
174
|
version: '1', audience: 'operators', intent: 'explain', format: 'outreach',
|
|
163
175
|
});
|
|
164
176
|
assert.match(briefTask.prompt, /# WritingBrief/);
|
|
165
|
-
assert.equal(HYV_VERSION, '3.3.
|
|
177
|
+
assert.equal(HYV_VERSION, '3.3.2');
|
|
166
178
|
});
|
|
167
179
|
test('apply rejects forged tasks, missing capability, and substituted profiles', () => {
|
|
168
180
|
const reduction = rebuildRecommendation();
|
|
@@ -177,3 +189,18 @@ test('apply rejects forged tasks, missing capability, and substituted profiles',
|
|
|
177
189
|
const other = buildProfile(['I speak in a different register altogether.', 'I keep every sentence longer than the first profile would.'], ['mechanism']);
|
|
178
190
|
assert.throws(() => evaluate(task, response, reduction, other), /Rebuild profile binding does not match this task/);
|
|
179
191
|
});
|
|
192
|
+
test('meaning-first rebuild binds the residual policy and blocks carried-over wording', () => {
|
|
193
|
+
const source = 'The operating review keeps the launch checklist small and the handoff calm. The launch is on 14 August.';
|
|
194
|
+
const reduction = rebuildRecommendation(source);
|
|
195
|
+
const task = prepareRebuildTask(source, profile, reduction, copySpec, capability(reduction, {}, source), trustStore, 150, undefined, recompositionPolicy);
|
|
196
|
+
assert.doesNotMatch(task.prompt, /The operating review keeps the launch checklist/);
|
|
197
|
+
assert.match(task.prompt, /Meaning-first recomposition contract/);
|
|
198
|
+
const repeated = evaluateRebuildResponse(task, { version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint, candidate: source }, profile, capability(reduction, {}, source), trustStore, 150);
|
|
199
|
+
assert.equal(repeated.status, 'needs_escalation');
|
|
200
|
+
assert.equal(repeated.failures[0]?.code, 'lexical_residual_exceeds_policy');
|
|
201
|
+
assert.equal(repeated.receipt.lexicalResidual?.passed, false);
|
|
202
|
+
assert.equal(repeated.candidate, undefined);
|
|
203
|
+
const fresh = evaluateRebuildResponse(task, { version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint, candidate: 'Release owners now work from a compact calendar note. The launch is on 14 August. Nothing else in this message repeats the original framing.' }, profile, capability(reduction, {}, source), trustStore, 150);
|
|
204
|
+
assert.equal(fresh.status, 'needs_semantic_review');
|
|
205
|
+
assert.equal(fresh.receipt.lexicalResidual?.passed, true);
|
|
206
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { canonicalJson } from './canonical-json.js';
|
|
2
|
+
import { words } from './text.js';
|
|
3
|
+
const ACKNOWLEDGEMENT = 'Measures shared wording only; does not detect or prove removal of a watermark.';
|
|
4
|
+
const STATEMENT = 'Lexical overlap is not a watermark detector.';
|
|
5
|
+
function tokens(text) {
|
|
6
|
+
return words(text.normalize('NFKC')).map((word) => word.toLowerCase());
|
|
7
|
+
}
|
|
8
|
+
function ngrams(value, size) {
|
|
9
|
+
const result = [];
|
|
10
|
+
for (let index = 0; index + size <= value.length; index += 1)
|
|
11
|
+
result.push(value.slice(index, index + size).join('\u0001'));
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
function allowedPhrases(copySpec) {
|
|
15
|
+
const result = [];
|
|
16
|
+
for (const claim of copySpec.claims) {
|
|
17
|
+
if (claim.atoms?.length)
|
|
18
|
+
result.push(...claim.atoms.map((text) => ({ reason: 'copy-spec-atom', text })));
|
|
19
|
+
else
|
|
20
|
+
result.push({ reason: 'copy-spec-claim', text: claim.text });
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
function longestSharedRun(source, candidate, allowed) {
|
|
25
|
+
let longest = 0;
|
|
26
|
+
let previous = new Array(source.length + 1).fill(0);
|
|
27
|
+
for (let candidateIndex = 1; candidateIndex <= candidate.length; candidateIndex += 1) {
|
|
28
|
+
const current = new Array(source.length + 1).fill(0);
|
|
29
|
+
for (let sourceIndex = 1; sourceIndex <= source.length; sourceIndex += 1) {
|
|
30
|
+
if (candidate[candidateIndex - 1] !== source[sourceIndex - 1])
|
|
31
|
+
continue;
|
|
32
|
+
current[sourceIndex] = previous[sourceIndex - 1] + 1;
|
|
33
|
+
const run = current[sourceIndex];
|
|
34
|
+
const key = candidate.slice(candidateIndex - run, candidateIndex).join('\u0001');
|
|
35
|
+
if (!allowed.has(key))
|
|
36
|
+
longest = Math.max(longest, run);
|
|
37
|
+
}
|
|
38
|
+
previous = current;
|
|
39
|
+
}
|
|
40
|
+
return longest;
|
|
41
|
+
}
|
|
42
|
+
export function parseRecompositionPolicy(value) {
|
|
43
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
44
|
+
throw new Error('Recomposition policy must be an object.');
|
|
45
|
+
const policy = value;
|
|
46
|
+
const residual = policy.lexicalResidual;
|
|
47
|
+
if (policy.version !== '1' || policy.mode !== 'meaning-first' || policy.acknowledgement !== ACKNOWLEDGEMENT
|
|
48
|
+
|| !residual || residual.ngramSize !== 5
|
|
49
|
+
|| typeof residual.maxSharedNgramFraction !== 'number' || !Number.isFinite(residual.maxSharedNgramFraction) || residual.maxSharedNgramFraction < 0 || residual.maxSharedNgramFraction > 1
|
|
50
|
+
|| !Number.isInteger(residual.maxLongestSharedRunTokens) || residual.maxLongestSharedRunTokens < 0 || residual.maxLongestSharedRunTokens > 100_000) {
|
|
51
|
+
throw new Error('Recomposition policy is not valid.');
|
|
52
|
+
}
|
|
53
|
+
return policy;
|
|
54
|
+
}
|
|
55
|
+
export function buildRecompositionBrief(copySpec, writingBrief) {
|
|
56
|
+
return [
|
|
57
|
+
'# Meaning-first recomposition contract',
|
|
58
|
+
'Write a new whole-document candidate from the structured facts and constraints below.',
|
|
59
|
+
'Do not edit, quote, or mirror source wording unless a CopySpec claim or atom requires it.',
|
|
60
|
+
'Return only the candidate. Do not claim anything about authorship, AI origin, or watermark status.',
|
|
61
|
+
'',
|
|
62
|
+
'# CopySpec',
|
|
63
|
+
canonicalJson({ audience: copySpec.audience, intent: copySpec.intent, channel: copySpec.channel, claims: copySpec.claims, ...(copySpec.prohibitedClaims ? { prohibitedClaims: copySpec.prohibitedClaims } : {}) }),
|
|
64
|
+
...(writingBrief ? ['', '# WritingBrief', canonicalJson(writingBrief)] : []),
|
|
65
|
+
].join('\n');
|
|
66
|
+
}
|
|
67
|
+
export function measureLexicalResidual(sourceText, candidateText, copySpec, policy) {
|
|
68
|
+
const source = tokens(sourceText);
|
|
69
|
+
const candidate = tokens(candidateText);
|
|
70
|
+
const allowed = allowedPhrases(copySpec);
|
|
71
|
+
const allowedNgrams = new Set();
|
|
72
|
+
const allowedRuns = new Set();
|
|
73
|
+
const counts = new Map();
|
|
74
|
+
for (const phrase of allowed) {
|
|
75
|
+
const phraseTokens = tokens(phrase.text);
|
|
76
|
+
counts.set(phrase.reason, (counts.get(phrase.reason) ?? 0) + phraseTokens.length);
|
|
77
|
+
for (const ngram of ngrams(phraseTokens, policy.lexicalResidual.ngramSize))
|
|
78
|
+
allowedNgrams.add(ngram);
|
|
79
|
+
for (let length = 1; length <= phraseTokens.length; length += 1) {
|
|
80
|
+
for (let index = 0; index + length <= phraseTokens.length; index += 1)
|
|
81
|
+
allowedRuns.add(phraseTokens.slice(index, index + length).join('\u0001'));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const sourceNgrams = new Set(ngrams(source, policy.lexicalResidual.ngramSize).filter((ngram) => !allowedNgrams.has(ngram)));
|
|
85
|
+
const candidateNgrams = ngrams(candidate, policy.lexicalResidual.ngramSize).filter((ngram) => !allowedNgrams.has(ngram));
|
|
86
|
+
const shared = candidateNgrams.filter((ngram) => sourceNgrams.has(ngram)).length;
|
|
87
|
+
const sharedNgramFraction = candidateNgrams.length === 0 ? 0 : shared / candidateNgrams.length;
|
|
88
|
+
const longestSharedRunTokens = longestSharedRun(source, candidate, allowedRuns);
|
|
89
|
+
const passed = sharedNgramFraction <= policy.lexicalResidual.maxSharedNgramFraction
|
|
90
|
+
&& longestSharedRunTokens <= policy.lexicalResidual.maxLongestSharedRunTokens;
|
|
91
|
+
return {
|
|
92
|
+
version: '1', sourceTokenCount: source.length, candidateTokenCount: candidate.length, ngramSize: policy.lexicalResidual.ngramSize,
|
|
93
|
+
sharedNgramFraction, longestSharedRunTokens,
|
|
94
|
+
allowedResiduals: [...counts.entries()].map(([reason, count]) => ({ reason, count })),
|
|
95
|
+
passed, statement: STATEMENT,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { buildRecompositionBrief, measureLexicalResidual, parseRecompositionPolicy } from './recomposition.js';
|
|
4
|
+
const policy = {
|
|
5
|
+
version: '1', mode: 'meaning-first',
|
|
6
|
+
lexicalResidual: { ngramSize: 5, maxSharedNgramFraction: 0, maxLongestSharedRunTokens: 4 },
|
|
7
|
+
acknowledgement: 'Measures shared wording only; does not detect or prove removal of a watermark.',
|
|
8
|
+
};
|
|
9
|
+
const copySpec = {
|
|
10
|
+
version: '1', audience: 'operators', intent: 'explain', channel: 'email',
|
|
11
|
+
claims: [{ id: 'launch-date', text: 'The launch is on 14 August.', evidence: 'Release calendar.', atoms: ['The launch is on 14 August.'] }],
|
|
12
|
+
};
|
|
13
|
+
test('parses only explicit meaning-first recomposition policies', () => {
|
|
14
|
+
assert.deepEqual(parseRecompositionPolicy(policy), policy);
|
|
15
|
+
assert.throws(() => parseRecompositionPolicy({ ...policy, lexicalResidual: { ...policy.lexicalResidual, ngramSize: 4 } }), /not valid/);
|
|
16
|
+
assert.throws(() => parseRecompositionPolicy({ ...policy, acknowledgement: 'watermark removed' }), /not valid/);
|
|
17
|
+
});
|
|
18
|
+
test('builds a recomposition brief without carrying the source draft', () => {
|
|
19
|
+
const brief = buildRecompositionBrief(copySpec, { version: '1', audience: 'operators', intent: 'explain', format: 'outreach' });
|
|
20
|
+
assert.match(brief, /Meaning-first recomposition contract/);
|
|
21
|
+
assert.match(brief, /The launch is on 14 August/);
|
|
22
|
+
assert.doesNotMatch(brief, /watermark-free|dewatermarked/i);
|
|
23
|
+
});
|
|
24
|
+
test('measures lexical carry-over without calling it watermark detection', () => {
|
|
25
|
+
const source = 'The operating review keeps the launch checklist small and the handoff calm. The launch is on 14 August.';
|
|
26
|
+
const identical = measureLexicalResidual(source, source, copySpec, policy);
|
|
27
|
+
assert.equal(identical.passed, false);
|
|
28
|
+
assert.ok(identical.sharedNgramFraction > 0);
|
|
29
|
+
assert.match(identical.statement, /not a watermark detector/);
|
|
30
|
+
const fresh = measureLexicalResidual(source, 'Release owners now work from a compact calendar note. The launch is on 14 August. Nothing else in this message repeats the original framing.', copySpec, policy);
|
|
31
|
+
assert.equal(fresh.passed, true);
|
|
32
|
+
assert.equal(fresh.sharedNgramFraction, 0);
|
|
33
|
+
assert.ok(fresh.allowedResiduals.some((item) => item.reason === 'copy-spec-atom'));
|
|
34
|
+
});
|
package/dist/rewrite-task.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { canonicalJson } from './canonical-json.js';
|
|
3
3
|
import { parseWritingBrief } from './editorial-packs.js';
|
|
4
|
-
import { hygieneSourceFindings } from './hygiene.js';
|
|
4
|
+
import { finalOutputCheck, hygieneSourceFindings } from './hygiene.js';
|
|
5
5
|
import { analyze, deriveEditScope, renderRewritePrompt, verifyDeterministically } from './pipeline.js';
|
|
6
6
|
import { sentences } from './text.js';
|
|
7
7
|
const MAX_RESPONSE_BYTES = 100_000;
|
|
@@ -251,11 +251,17 @@ export function evaluateRewriteResponse(task, raw, profile) {
|
|
|
251
251
|
const applied = applyRewriteResponse(task, raw);
|
|
252
252
|
if (applied.status !== 'accepted' || !applied.candidate)
|
|
253
253
|
return applied;
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
254
|
+
const output = finalOutputCheck(applied.candidate);
|
|
255
|
+
const candidate = output.accepted ? output.output : applied.candidate;
|
|
256
|
+
const checked = verifyDeterministically(task.draft, candidate, profile, task.copySpec, task.writingBrief);
|
|
257
|
+
const verification = { ...checked.verification, finalOutput: output };
|
|
258
|
+
const deterministicArtifact = checked.artifact;
|
|
259
|
+
if (!verification.passed) {
|
|
260
|
+
const { candidate: _candidate, ...withheld } = applied;
|
|
261
|
+
return { ...withheld, status: 'needs_escalation', verification };
|
|
262
|
+
}
|
|
257
263
|
const lifecycleBinding = createRewriteLifecycleBinding(task, applied.receipt, deterministicArtifact);
|
|
258
|
-
return { ...applied, status: 'needs_semantic_review', verification, deterministicArtifact, lifecycleBinding };
|
|
264
|
+
return { ...applied, candidate, status: 'needs_semantic_review', verification, deterministicArtifact, lifecycleBinding };
|
|
259
265
|
}
|
|
260
266
|
export function createRewriteLifecycleBinding(task, receipt, deterministic) {
|
|
261
267
|
if (!deterministic.passed || receipt.taskFingerprint !== task.fingerprint)
|
|
@@ -116,6 +116,21 @@ test('keeps a valid response byte-for-byte unchanged by repair adapters', () =>
|
|
|
116
116
|
assert.deepEqual(result.receipt.adapterIds, []);
|
|
117
117
|
assert.deepEqual(result.receipt.replacementSentenceIds, [1]);
|
|
118
118
|
});
|
|
119
|
+
test('withholds an unresolved final-output candidate from evaluation results', () => {
|
|
120
|
+
const task = prepareRewriteTask('I leverage the answer.', profile);
|
|
121
|
+
const result = evaluateRewriteResponse(task, { version: '1', taskFingerprint: task.fingerprint, replacements: [{ sentenceId: 1, text: 'I use the answer.\u200B' }] }, profile);
|
|
122
|
+
assert.equal(result.status, 'needs_escalation');
|
|
123
|
+
assert.equal(result.candidate, undefined);
|
|
124
|
+
assert.equal(result.verification?.finalOutput.accepted, false);
|
|
125
|
+
});
|
|
126
|
+
test('returns the final-gate-cleaned candidate and hashes that value', () => {
|
|
127
|
+
const task = prepareRewriteTask('I leverage the answer with useful detail and clear mechanism.', profile);
|
|
128
|
+
const result = evaluateRewriteResponse(task, { version: '1', taskFingerprint: task.fingerprint, replacements: [{ sentenceId: 1, text: 'I use the\u0007 answer with useful detail and clear mechanism.' }] }, profile);
|
|
129
|
+
assert.equal(result.status, 'needs_semantic_review');
|
|
130
|
+
assert.equal(result.candidate, 'I use the answer with useful detail and clear mechanism.');
|
|
131
|
+
assert.equal(result.verification?.finalOutput.changed, true);
|
|
132
|
+
assert.equal(result.deterministicArtifact?.candidateHash, createHash('sha256').update('I use the answer with useful detail and clear mechanism.').digest('hex'));
|
|
133
|
+
});
|
|
119
134
|
test('binds the task, response, deterministic artifact, text hashes, and profile revision', () => {
|
|
120
135
|
const draft = 'I write clear notes.';
|
|
121
136
|
const task = prepareRewriteTask(draft, profile);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
import { applyHiddenTextPolicy, inspectHiddenText } from './hidden-text.js';
|
|
5
|
+
import { provenanceStatusForRebuild, writerRequestForRebuild } from './provenance-status.js';
|
|
6
|
+
const feature = readFileSync(new URL('../features/text-provenance.feature', import.meta.url), 'utf8');
|
|
7
|
+
const policy = { version: '1', mode: 'meaning-first', lexicalResidual: { ngramSize: 5, maxSharedNgramFraction: 0.1, maxLongestSharedRunTokens: 8 }, acknowledgement: 'Measures shared wording only; does not detect or prove removal of a watermark.' };
|
|
8
|
+
const task = {
|
|
9
|
+
version: '1', fingerprint: 'a'.repeat(64), draft: 'private source phrase', prompt: 'structured task prompt',
|
|
10
|
+
copySpec: { version: '1', audience: 'operators', intent: 'explain', channel: 'email', claims: [{ id: 'date', text: 'The date is fixed.', evidence: 'calendar' }] },
|
|
11
|
+
recommendationFingerprint: 'b'.repeat(64), authorizationFingerprint: 'c'.repeat(64), profileId: 'profile', profileRevisionDigest: 'd'.repeat(64), recompositionPolicy: policy,
|
|
12
|
+
};
|
|
13
|
+
function scenario(name, verify) {
|
|
14
|
+
test(`Feature: bounded text provenance sanitation — Scenario: ${name}`, () => {
|
|
15
|
+
assert.match(feature, new RegExp(`Scenario: ${name}`));
|
|
16
|
+
verify();
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
scenario('explicitly approved non-semantic controls are removed with evidence', () => {
|
|
20
|
+
const receipt = applyHiddenTextPolicy('one\u0007two\uFEFFthree');
|
|
21
|
+
assert.deepEqual(receipt.proposedChanges.map((item) => item.codepoint), ['U+0007', 'U+FEFF']);
|
|
22
|
+
assert.match(receipt.inputHash, /^[a-f0-9]{64}$/);
|
|
23
|
+
assert.match(receipt.outputHash, /^[a-f0-9]{64}$/);
|
|
24
|
+
assert.equal(receipt.idempotent, true);
|
|
25
|
+
});
|
|
26
|
+
scenario('multilingual and structured text is review-only by default', () => {
|
|
27
|
+
const source = '```md\nالعربية\u202E ไทย\u200B 👩\u200D💻\t\n```';
|
|
28
|
+
const receipt = applyHiddenTextPolicy(source);
|
|
29
|
+
assert.equal(receipt.output, source);
|
|
30
|
+
assert.equal(receipt.proposedChanges.length, 0);
|
|
31
|
+
assert.ok(inspectHiddenText(source).findings.every((item) => item.action === 'review'));
|
|
32
|
+
});
|
|
33
|
+
scenario('an external writer receives no source prose', () => {
|
|
34
|
+
const request = writerRequestForRebuild(task);
|
|
35
|
+
assert.match(request.prompt, /structured task prompt/);
|
|
36
|
+
assert.doesNotMatch(JSON.stringify(request), /private source phrase|authorizationFingerprint|profileRevisionDigest|draft/);
|
|
37
|
+
});
|
|
38
|
+
scenario('unknown provider status remains explicit', () => {
|
|
39
|
+
assert.deepEqual(provenanceStatusForRebuild(task), { version: '1', state: 'unknown', reason: 'private_or_unavailable_verifier' });
|
|
40
|
+
assert.match(feature, /does not claim watermark removal or absence/);
|
|
41
|
+
});
|
|
42
|
+
scenario('a controlled verifier is not configured', () => {
|
|
43
|
+
assert.deepEqual(provenanceStatusForRebuild({ ...task, recompositionPolicy: undefined }), { version: '1', state: 'not_configured' });
|
|
44
|
+
assert.match(feature, /lexical residual is not used as a provider verifier/);
|
|
45
|
+
});
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const HYV_VERSION = '3.3.
|
|
1
|
+
export const HYV_VERSION = '3.3.2';
|
package/package.json
CHANGED