@holdyourvoice/hyv 3.1.1 → 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 +76 -17
- package/dist/ai-editor-rules.js +151 -0
- package/dist/ai-editor.js +104 -8
- package/dist/ai-editor.test.js +135 -22
- 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 +359 -21
- package/dist/cli.test.js +275 -7
- package/dist/copy-spec.js +35 -8
- package/dist/editorial-packs.js +25 -1
- package/dist/editorial-packs.test.js +45 -0
- package/dist/hygiene.js +91 -0
- package/dist/hygiene.test.js +73 -0
- 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 +110 -9
- package/dist/mcp-tools.test.js +188 -10
- package/dist/mcp.js +228 -9
- package/dist/mcp.test.js +248 -12
- package/dist/pipeline.js +81 -15
- package/dist/pipeline.test.js +94 -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 +144 -2
- package/dist/rewrite-task.js +136 -16
- package/dist/rewrite-task.test.js +72 -4
- 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 -0
- package/dist/voice-dna.js +53 -1
- package/dist/voice-dna.test.js +79 -1
- package/package.json +2 -2
|
@@ -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
|
+
});
|
package/dist/profile.js
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { canonicalJson } from './canonical-json.js';
|
|
3
|
+
const METRICS_KEYS = ['sentenceLength', 'sentenceVariation', 'sentenceStructure', 'rhythm', 'paragraphLength', 'openingMoves', 'vocabulary', 'lexicalDensity', 'pointOfView', 'punctuation', 'caseStyle', 'questionRate', 'transitions'];
|
|
4
|
+
const PROFILE_V3_KEYS = ['version', 'id', 'revision', 'revisionDigest', 'sampleCount', 'metrics', 'avoid', 'provenance', 'rulePolicy', 'fingerprint', 'tolerances', 'metricFixtures'];
|
|
5
|
+
const FINGERPRINT_METRICS = ['contractionRate', 'sentenceLengthDistribution', 'bulletRate', 'enDashRate'];
|
|
6
|
+
const STABLE_ID = /^[a-z0-9](?:[a-z0-9._-]{0,127})$/;
|
|
7
|
+
function isPlainObject(value) {
|
|
8
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
|
|
9
|
+
}
|
|
10
|
+
function hasKnownKeys(value, keys) {
|
|
11
|
+
return Object.keys(value).every((key) => keys.includes(key)) && keys.every((key) => key in value);
|
|
12
|
+
}
|
|
13
|
+
function isBoundedString(value, maximum = 256) {
|
|
14
|
+
return typeof value === 'string' && value.trim().length > 0 && value.length <= maximum;
|
|
15
|
+
}
|
|
16
|
+
function isRate(value) {
|
|
17
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
|
|
18
|
+
}
|
|
19
|
+
function isBoundedStringArray(value, minimum = 0) {
|
|
20
|
+
return Array.isArray(value) && value.length >= minimum && value.length <= 64
|
|
21
|
+
&& value.every((item) => isBoundedString(item)) && new Set(value).size === value.length;
|
|
22
|
+
}
|
|
1
23
|
function isNumberRecord(value) {
|
|
2
24
|
return value !== null && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
|
|
3
25
|
&& Object.values(value).every((item) => typeof item === 'number' && Number.isFinite(item));
|
|
@@ -20,10 +42,75 @@ function isMetrics(value) {
|
|
|
20
42
|
&& stringArrays.every((items) => Array.isArray(items) && items.every((item) => typeof item === 'string'))
|
|
21
43
|
&& isPunctuation(metrics.punctuation);
|
|
22
44
|
}
|
|
45
|
+
function isStrictMetrics(value) {
|
|
46
|
+
return isPlainObject(value) && hasKnownKeys(value, METRICS_KEYS) && isMetrics(value)
|
|
47
|
+
&& [value.sentenceStructure, value.openingMoves, value.vocabulary, value.transitions].every((items) => isBoundedStringArray(items));
|
|
48
|
+
}
|
|
49
|
+
function isProvenance(value) {
|
|
50
|
+
if (!isPlainObject(value) || !hasKnownKeys(value, ['source', 'rights', 'createdAt']))
|
|
51
|
+
return false;
|
|
52
|
+
if (!isBoundedString(value.source) || !isBoundedString(value.rights) || !isBoundedString(value.createdAt, 64))
|
|
53
|
+
return false;
|
|
54
|
+
const parsed = new Date(value.createdAt);
|
|
55
|
+
return !Number.isNaN(parsed.valueOf()) && parsed.toISOString() === value.createdAt;
|
|
56
|
+
}
|
|
57
|
+
function isRulePolicy(value) {
|
|
58
|
+
if (!isPlainObject(value) || Object.keys(value).length > 512)
|
|
59
|
+
return false;
|
|
60
|
+
const states = ['blocking', 'advisory', 'judgment-required', 'disabled'];
|
|
61
|
+
return Object.entries(value).every(([id, state]) => STABLE_ID.test(id) && states.includes(state));
|
|
62
|
+
}
|
|
63
|
+
function isFingerprint(value) {
|
|
64
|
+
if (!isPlainObject(value) || !hasKnownKeys(value, FINGERPRINT_METRICS))
|
|
65
|
+
return false;
|
|
66
|
+
const distribution = value.sentenceLengthDistribution;
|
|
67
|
+
if (!isPlainObject(distribution) || !hasKnownKeys(distribution, ['short', 'medium', 'long']))
|
|
68
|
+
return false;
|
|
69
|
+
const parts = [distribution.short, distribution.medium, distribution.long];
|
|
70
|
+
return isRate(value.contractionRate) && isRate(value.bulletRate) && isRate(value.enDashRate)
|
|
71
|
+
&& parts.every(isRate) && Math.abs(parts.reduce((sum, item) => sum + item, 0) - 1) < 1e-9;
|
|
72
|
+
}
|
|
73
|
+
function isTolerances(value) {
|
|
74
|
+
return isPlainObject(value) && hasKnownKeys(value, FINGERPRINT_METRICS) && FINGERPRINT_METRICS.every((key) => {
|
|
75
|
+
const tolerance = value[key];
|
|
76
|
+
return isPlainObject(tolerance) && hasKnownKeys(tolerance, ['absolute', 'calibrated'])
|
|
77
|
+
&& isRate(tolerance.absolute) && typeof tolerance.calibrated === 'boolean';
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function isMetricFixtures(value) {
|
|
81
|
+
return isPlainObject(value) && hasKnownKeys(value, FINGERPRINT_METRICS)
|
|
82
|
+
&& FINGERPRINT_METRICS.every((key) => isBoundedStringArray(value[key], 1) && value[key].every((id) => STABLE_ID.test(id)));
|
|
83
|
+
}
|
|
84
|
+
function hasValidRevisionDigest(profile) {
|
|
85
|
+
if (typeof profile.revisionDigest !== 'string' || !/^[a-f0-9]{64}$/.test(profile.revisionDigest))
|
|
86
|
+
return false;
|
|
87
|
+
const { revisionDigest, ...unsigned } = profile;
|
|
88
|
+
return createHash('sha256').update(canonicalJson(unsigned)).digest('hex') === revisionDigest;
|
|
89
|
+
}
|
|
90
|
+
function parseProfileV3(value) {
|
|
91
|
+
const valid = isPlainObject(value) && hasKnownKeys(value, PROFILE_V3_KEYS)
|
|
92
|
+
&& typeof value.id === 'string' && STABLE_ID.test(value.id)
|
|
93
|
+
&& typeof value.revision === 'number' && Number.isSafeInteger(value.revision) && value.revision > 0
|
|
94
|
+
&& typeof value.sampleCount === 'number' && Number.isInteger(value.sampleCount) && value.sampleCount >= 2
|
|
95
|
+
&& isStrictMetrics(value.metrics)
|
|
96
|
+
&& isBoundedStringArray(value.avoid)
|
|
97
|
+
&& isProvenance(value.provenance)
|
|
98
|
+
&& isRulePolicy(value.rulePolicy)
|
|
99
|
+
&& isFingerprint(value.fingerprint)
|
|
100
|
+
&& isTolerances(value.tolerances)
|
|
101
|
+
&& isMetricFixtures(value.metricFixtures);
|
|
102
|
+
if (!valid)
|
|
103
|
+
throw new Error('Profile is not a valid Hold Your Voice version 3 profile. Rebuild it from fixture-backed metrics.');
|
|
104
|
+
if (!hasValidRevisionDigest(value))
|
|
105
|
+
throw new Error('Profile version 3 revision digest does not match its canonical contents.');
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
23
108
|
export function parseProfile(value) {
|
|
24
109
|
if (!value || typeof value !== 'object')
|
|
25
110
|
throw new Error('Profile must be a JSON object.');
|
|
26
111
|
const profile = value;
|
|
112
|
+
if (profile.version === '3')
|
|
113
|
+
return parseProfileV3(profile);
|
|
27
114
|
if (profile.version !== '2' || typeof profile.sampleCount !== 'number' || !Number.isInteger(profile.sampleCount) || profile.sampleCount < 2 || !isMetrics(profile.metrics) || !Array.isArray(profile.avoid) || !profile.avoid.every((item) => typeof item === 'string' && item.trim().length > 0)) {
|
|
28
115
|
throw new Error('Profile is not a valid Hold Your Voice version 2 profile. Rebuild it with the profile command.');
|
|
29
116
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
import { parseProfile } from './profile.js';
|
|
5
|
+
function canonicalJson(value) {
|
|
6
|
+
if (Array.isArray(value))
|
|
7
|
+
return `[${value.map(canonicalJson).join(',')}]`;
|
|
8
|
+
if (value !== null && typeof value === 'object') {
|
|
9
|
+
return `{${Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(',')}}`;
|
|
10
|
+
}
|
|
11
|
+
return JSON.stringify(value);
|
|
12
|
+
}
|
|
13
|
+
function profileV3() {
|
|
14
|
+
const unsigned = {
|
|
15
|
+
version: '3',
|
|
16
|
+
id: 'founder.primary',
|
|
17
|
+
revision: 1,
|
|
18
|
+
sampleCount: 2,
|
|
19
|
+
metrics: {
|
|
20
|
+
sentenceLength: 7,
|
|
21
|
+
sentenceVariation: 2,
|
|
22
|
+
sentenceStructure: ['i name the'],
|
|
23
|
+
rhythm: 2,
|
|
24
|
+
paragraphLength: 2,
|
|
25
|
+
openingMoves: ['i'],
|
|
26
|
+
vocabulary: ['mechanism'],
|
|
27
|
+
lexicalDensity: 0.5,
|
|
28
|
+
pointOfView: 'first_person',
|
|
29
|
+
punctuation: { '!': 0, '?': 0, ';': 0, ':': 0, '—': 0 },
|
|
30
|
+
caseStyle: 'lowercase',
|
|
31
|
+
questionRate: 0,
|
|
32
|
+
transitions: ['but'],
|
|
33
|
+
},
|
|
34
|
+
avoid: ['unlock'],
|
|
35
|
+
provenance: { source: 'local-author-owned-samples', rights: 'author-owned', createdAt: '2026-08-13T00:00:00.000Z' },
|
|
36
|
+
rulePolicy: {
|
|
37
|
+
'ai.antithesis': 'advisory',
|
|
38
|
+
'ai.staccato': 'judgment-required',
|
|
39
|
+
'ai.generic': 'blocking',
|
|
40
|
+
'ai.question-hook': 'disabled',
|
|
41
|
+
},
|
|
42
|
+
fingerprint: {
|
|
43
|
+
contractionRate: 0.3,
|
|
44
|
+
sentenceLengthDistribution: { short: 0.2, medium: 0.5, long: 0.3 },
|
|
45
|
+
bulletRate: 0.1,
|
|
46
|
+
enDashRate: 0.05,
|
|
47
|
+
},
|
|
48
|
+
tolerances: {
|
|
49
|
+
contractionRate: { absolute: 0.1, calibrated: true },
|
|
50
|
+
sentenceLengthDistribution: { absolute: 0.15, calibrated: false },
|
|
51
|
+
bulletRate: { absolute: 0.1, calibrated: false },
|
|
52
|
+
enDashRate: { absolute: 0.05, calibrated: false },
|
|
53
|
+
},
|
|
54
|
+
metricFixtures: {
|
|
55
|
+
contractionRate: ['fixture.contractions'],
|
|
56
|
+
sentenceLengthDistribution: ['fixture.sentences'],
|
|
57
|
+
bulletRate: ['fixture.bullets'],
|
|
58
|
+
enDashRate: ['fixture.en-dashes'],
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
...unsigned,
|
|
63
|
+
revisionDigest: createHash('sha256').update(canonicalJson(unsigned)).digest('hex'),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
test('keeps Profile v2 parsing and runtime shape byte-for-byte compatible', () => {
|
|
67
|
+
const profile = {
|
|
68
|
+
version: '2', sampleCount: 2,
|
|
69
|
+
metrics: {
|
|
70
|
+
sentenceLength: 4, sentenceVariation: 1, sentenceStructure: [], rhythm: 1, paragraphLength: 1,
|
|
71
|
+
openingMoves: [], vocabulary: [], lexicalDensity: 0.5, pointOfView: 'mixed',
|
|
72
|
+
punctuation: { '!': 0, '?': 0, ';': 0, ':': 0, '—': 0 }, caseStyle: 'mixed', questionRate: 0, transitions: [],
|
|
73
|
+
},
|
|
74
|
+
avoid: [],
|
|
75
|
+
legacyExtension: true,
|
|
76
|
+
};
|
|
77
|
+
assert.strictEqual(parseProfile(profile), profile);
|
|
78
|
+
});
|
|
79
|
+
test('parses a strict Profile v3 with all four rule policy states and fixture-backed metrics', () => {
|
|
80
|
+
const profile = profileV3();
|
|
81
|
+
assert.strictEqual(parseProfile(profile), profile);
|
|
82
|
+
});
|
|
83
|
+
test('rejects a changed Profile v3 revision digest', () => {
|
|
84
|
+
const profile = profileV3();
|
|
85
|
+
profile.fingerprint.bulletRate = 0.2;
|
|
86
|
+
assert.throws(() => parseProfile(profile), /revision digest/);
|
|
87
|
+
});
|
|
88
|
+
test('rejects malformed Profile v3 identity, policy, provenance, and unknown keys', () => {
|
|
89
|
+
for (const mutate of [
|
|
90
|
+
(profile) => { profile.id = '../founder'; },
|
|
91
|
+
(profile) => { profile.revision = 0; },
|
|
92
|
+
(profile) => { profile.rulePolicy['ai.generic'] = 'warn'; },
|
|
93
|
+
(profile) => { profile.provenance.source = ''; },
|
|
94
|
+
(profile) => { profile.extra = true; },
|
|
95
|
+
]) {
|
|
96
|
+
const profile = profileV3();
|
|
97
|
+
mutate(profile);
|
|
98
|
+
assert.throws(() => parseProfile(profile), /version 3 profile/);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
test('rejects unbounded or invalid Profile v3 metrics and tolerances', () => {
|
|
102
|
+
for (const mutate of [
|
|
103
|
+
(profile) => { profile.fingerprint.contractionRate = Number.NaN; },
|
|
104
|
+
(profile) => { profile.fingerprint.sentenceLengthDistribution = { short: 0.2, medium: 0.2, long: 0.2 }; },
|
|
105
|
+
(profile) => { profile.tolerances.bulletRate.absolute = 1.1; },
|
|
106
|
+
(profile) => { profile.tolerances.enDashRate.calibrated = 'yes'; },
|
|
107
|
+
(profile) => { profile.metricFixtures.enDashRate = []; },
|
|
108
|
+
(profile) => { profile.metricFixtures.bulletRate = Array.from({ length: 65 }, (_, index) => `fixture.${index}`); },
|
|
109
|
+
]) {
|
|
110
|
+
const profile = profileV3();
|
|
111
|
+
mutate(profile);
|
|
112
|
+
assert.throws(() => parseProfile(profile), /version 3 profile/);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { canonicalJson } from './canonical-json.js';
|
|
3
|
+
import { parseCopySpec } from './copy-spec.js';
|
|
4
|
+
import { parseWritingBrief } from './editorial-packs.js';
|
|
5
|
+
import { verifyApprovalCapability } from './approval-capability.js';
|
|
6
|
+
import { fingerprintPreEditReduction } from './judgment-task.js';
|
|
7
|
+
import { verifyRebuildDeterministically } from './pipeline.js';
|
|
8
|
+
import { sentences } from './text.js';
|
|
9
|
+
import { HYV_VERSION } from './version.js';
|
|
10
|
+
const MAX_RESPONSE_BYTES = 100_000;
|
|
11
|
+
const MAX_CANDIDATE_CHARACTERS = 100_000;
|
|
12
|
+
function fingerprint(value) {
|
|
13
|
+
return createHash('sha256').update(typeof value === 'string' ? value : canonicalJson(value)).digest('hex');
|
|
14
|
+
}
|
|
15
|
+
function digest(value) {
|
|
16
|
+
return createHash('sha256').update(value).digest('hex');
|
|
17
|
+
}
|
|
18
|
+
function digestCanonical(value) {
|
|
19
|
+
return digest(canonicalJson(value));
|
|
20
|
+
}
|
|
21
|
+
function failure(code, message, path) {
|
|
22
|
+
return { code, message, ...(path ? { path } : {}) };
|
|
23
|
+
}
|
|
24
|
+
function isFailure(value) {
|
|
25
|
+
return typeof value === 'object' && value !== null && 'code' in value && 'message' in value;
|
|
26
|
+
}
|
|
27
|
+
function profileIdentity(profile) {
|
|
28
|
+
if (profile.version === '3')
|
|
29
|
+
return { profileId: profile.id, profileRevisionDigest: profile.revisionDigest };
|
|
30
|
+
const legacy = `legacy-v2:${digestCanonical(profile)}`;
|
|
31
|
+
return { profileId: legacy, profileRevisionDigest: legacy };
|
|
32
|
+
}
|
|
33
|
+
function parseJson(value) {
|
|
34
|
+
if (Buffer.byteLength(value) > MAX_RESPONSE_BYTES)
|
|
35
|
+
return failure('response_too_large', `Response exceeds ${MAX_RESPONSE_BYTES} bytes.`);
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(value);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return failure('invalid_json', 'Response must be valid JSON.');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function parseRebuildResponse(value) {
|
|
44
|
+
const raw = typeof value === 'string' ? parseJson(value) : value;
|
|
45
|
+
if (isFailure(raw))
|
|
46
|
+
return raw;
|
|
47
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
48
|
+
return failure('invalid_response_shape', 'Response must be an object.');
|
|
49
|
+
const response = raw;
|
|
50
|
+
if ('replacements' in response || 'operations' in response) {
|
|
51
|
+
return failure('edit_response_on_rebuild_task', 'Edit responses cannot satisfy rebuild tasks.');
|
|
52
|
+
}
|
|
53
|
+
if (response.mode === 'SHIP')
|
|
54
|
+
return failure('edit_response_on_rebuild_task', 'Edit responses cannot satisfy rebuild tasks.', 'mode');
|
|
55
|
+
if (response.version !== '1')
|
|
56
|
+
return failure('invalid_response_version', 'Rebuild response version must be "1".', 'version');
|
|
57
|
+
if (response.mode !== 'REBUILD')
|
|
58
|
+
return failure('invalid_response_shape', 'Rebuild responses require mode REBUILD.', 'mode');
|
|
59
|
+
if (typeof response.taskFingerprint !== 'string' || response.taskFingerprint.length !== 64) {
|
|
60
|
+
return failure('invalid_response_shape', 'Response must include the task fingerprint.', 'taskFingerprint');
|
|
61
|
+
}
|
|
62
|
+
if (typeof response.candidate !== 'string')
|
|
63
|
+
return failure('invalid_candidate_text', 'Rebuild candidate must be a string.', 'candidate');
|
|
64
|
+
if (!response.candidate.trim() || response.candidate.length > MAX_CANDIDATE_CHARACTERS) {
|
|
65
|
+
return failure('invalid_candidate_text', `Rebuild candidate must contain at most ${MAX_CANDIDATE_CHARACTERS} characters.`, 'candidate');
|
|
66
|
+
}
|
|
67
|
+
return response;
|
|
68
|
+
}
|
|
69
|
+
function renderRebuildPrompt(draft, copySpec, writingBrief) {
|
|
70
|
+
return [
|
|
71
|
+
'# Rebuild contract',
|
|
72
|
+
'Return a whole-document candidate. Do not emit sentence replacements or range operations.',
|
|
73
|
+
'Keep every immutable CopySpec claim and atom. Do not add prohibited claims.',
|
|
74
|
+
'Claim, polarity, hygiene, fingerprint, and semantic gates remain blocking. Lexical survival is not required.',
|
|
75
|
+
'',
|
|
76
|
+
'# CopySpec',
|
|
77
|
+
canonicalJson({ audience: copySpec.audience, intent: copySpec.intent, channel: copySpec.channel, claims: copySpec.claims, ...(copySpec.prohibitedClaims ? { prohibitedClaims: copySpec.prohibitedClaims } : {}) }),
|
|
78
|
+
...(writingBrief ? ['', '# WritingBrief', canonicalJson(writingBrief)] : []),
|
|
79
|
+
'',
|
|
80
|
+
'# Draft',
|
|
81
|
+
draft,
|
|
82
|
+
].join('\n');
|
|
83
|
+
}
|
|
84
|
+
function authorizationBinding(draft, profile, recommendationFingerprint) {
|
|
85
|
+
const identity = profileIdentity(profile);
|
|
86
|
+
const sourceHash = digest(draft);
|
|
87
|
+
return {
|
|
88
|
+
rewriteTaskFingerprint: recommendationFingerprint,
|
|
89
|
+
rewriteResponseFingerprint: recommendationFingerprint,
|
|
90
|
+
deterministicArtifactFingerprint: recommendationFingerprint,
|
|
91
|
+
sourceHash,
|
|
92
|
+
candidateHash: sourceHash,
|
|
93
|
+
profileId: identity.profileId,
|
|
94
|
+
profileRevisionDigest: identity.profileRevisionDigest,
|
|
95
|
+
rulesetVersion: HYV_VERSION,
|
|
96
|
+
schemaVersion: '1',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function verifyRebuildAuthorization(draft, profile, recommendationFingerprint, capability, trustStore, now) {
|
|
100
|
+
const authorized = verifyApprovalCapability(capability, trustStore, {
|
|
101
|
+
now,
|
|
102
|
+
expectedSubjectArtifactFingerprint: recommendationFingerprint,
|
|
103
|
+
binding: authorizationBinding(draft, profile, recommendationFingerprint),
|
|
104
|
+
expectedPurpose: 'hyv.rebuild-authorization',
|
|
105
|
+
});
|
|
106
|
+
if (!authorized.ok)
|
|
107
|
+
throw new Error('Rebuild authorization is invalid.');
|
|
108
|
+
return authorized;
|
|
109
|
+
}
|
|
110
|
+
export function prepareRebuildTask(draft, profile, reduction, copySpec, capability, trustStore, now, writingBrief) {
|
|
111
|
+
if (reduction.decision !== 'REBUILD')
|
|
112
|
+
throw new Error('Rebuild requires an upstream REBUILD recommendation.');
|
|
113
|
+
if (fingerprintPreEditReduction(reduction) !== reduction.recommendationFingerprint || reduction.recommendationFingerprint.length !== 64) {
|
|
114
|
+
throw new Error('Rebuild requires an upstream REBUILD recommendation.');
|
|
115
|
+
}
|
|
116
|
+
const spec = parseCopySpec(copySpec);
|
|
117
|
+
const identity = profileIdentity(profile);
|
|
118
|
+
const authorized = verifyRebuildAuthorization(draft, profile, reduction.recommendationFingerprint, capability, trustStore, now);
|
|
119
|
+
if (writingBrief)
|
|
120
|
+
parseWritingBrief(writingBrief);
|
|
121
|
+
const taskBase = {
|
|
122
|
+
version: '1',
|
|
123
|
+
draft,
|
|
124
|
+
prompt: renderRebuildPrompt(draft, spec, writingBrief),
|
|
125
|
+
copySpec: spec,
|
|
126
|
+
recommendationFingerprint: reduction.recommendationFingerprint,
|
|
127
|
+
authorizationFingerprint: authorized.capabilityFingerprint,
|
|
128
|
+
profileId: identity.profileId,
|
|
129
|
+
profileRevisionDigest: identity.profileRevisionDigest,
|
|
130
|
+
...(writingBrief ? { writingBrief } : {}),
|
|
131
|
+
};
|
|
132
|
+
return { ...taskBase, fingerprint: fingerprint(taskBase) };
|
|
133
|
+
}
|
|
134
|
+
export function parseRebuildTask(value) {
|
|
135
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
136
|
+
throw new Error('Rebuild task must be an object.');
|
|
137
|
+
const task = value;
|
|
138
|
+
if (task.version !== '1' || typeof task.fingerprint !== 'string' || typeof task.draft !== 'string' || typeof task.prompt !== 'string'
|
|
139
|
+
|| typeof task.recommendationFingerprint !== 'string' || typeof task.authorizationFingerprint !== 'string'
|
|
140
|
+
|| typeof task.profileId !== 'string' || typeof task.profileRevisionDigest !== 'string' || !task.copySpec) {
|
|
141
|
+
throw new Error('Rebuild task does not match version 1.');
|
|
142
|
+
}
|
|
143
|
+
parseCopySpec(task.copySpec);
|
|
144
|
+
if (task.writingBrief !== undefined)
|
|
145
|
+
parseWritingBrief(task.writingBrief);
|
|
146
|
+
const { fingerprint: suppliedFingerprint, ...base } = task;
|
|
147
|
+
if (fingerprint(base) !== suppliedFingerprint)
|
|
148
|
+
throw new Error('Rebuild task fingerprint does not match its contents.');
|
|
149
|
+
return task;
|
|
150
|
+
}
|
|
151
|
+
function rejected(task, raw, failures) {
|
|
152
|
+
return {
|
|
153
|
+
status: 'repairable',
|
|
154
|
+
failures,
|
|
155
|
+
receipt: {
|
|
156
|
+
version: '1',
|
|
157
|
+
taskFingerprint: task.fingerprint,
|
|
158
|
+
responseFingerprint: fingerprint(raw),
|
|
159
|
+
adapterIds: [],
|
|
160
|
+
replacementSentenceIds: [],
|
|
161
|
+
mode: 'REBUILD',
|
|
162
|
+
recommendationFingerprint: task.recommendationFingerprint,
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
export function applyRebuildResponse(task, raw) {
|
|
167
|
+
const response = parseRebuildResponse(typeof raw === 'string' ? parseJson(raw) : raw);
|
|
168
|
+
if (isFailure(response))
|
|
169
|
+
return rejected(task, raw, [response]);
|
|
170
|
+
if (response.taskFingerprint !== task.fingerprint) {
|
|
171
|
+
return rejected(task, raw, [failure('task_fingerprint_mismatch', 'Response task fingerprint does not match this task.', 'taskFingerprint')]);
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
status: 'accepted',
|
|
175
|
+
candidate: response.candidate,
|
|
176
|
+
failures: [],
|
|
177
|
+
receipt: {
|
|
178
|
+
version: '1',
|
|
179
|
+
taskFingerprint: task.fingerprint,
|
|
180
|
+
responseFingerprint: fingerprint(raw),
|
|
181
|
+
adapterIds: [],
|
|
182
|
+
replacementSentenceIds: sentences(response.candidate).map((sentence) => sentence.index),
|
|
183
|
+
mode: 'REBUILD',
|
|
184
|
+
recommendationFingerprint: task.recommendationFingerprint,
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
export function evaluateRebuildResponse(task, raw, profile, capability, trustStore, now) {
|
|
189
|
+
const identity = profileIdentity(profile);
|
|
190
|
+
if (identity.profileId !== task.profileId || identity.profileRevisionDigest !== task.profileRevisionDigest) {
|
|
191
|
+
throw new Error('Rebuild profile binding does not match this task.');
|
|
192
|
+
}
|
|
193
|
+
const authorized = verifyRebuildAuthorization(task.draft, profile, task.recommendationFingerprint, capability, trustStore, now);
|
|
194
|
+
if (authorized.capabilityFingerprint !== task.authorizationFingerprint)
|
|
195
|
+
throw new Error('Rebuild authorization is invalid.');
|
|
196
|
+
const applied = applyRebuildResponse(task, raw);
|
|
197
|
+
if (applied.status !== 'accepted' || !applied.candidate)
|
|
198
|
+
return applied;
|
|
199
|
+
const { verification, artifact: deterministicArtifact } = verifyRebuildDeterministically(task.draft, applied.candidate, profile, task.copySpec, task.writingBrief);
|
|
200
|
+
const receipt = {
|
|
201
|
+
...applied.receipt,
|
|
202
|
+
preservationBypass: true,
|
|
203
|
+
authorizationFingerprint: authorized.capabilityFingerprint,
|
|
204
|
+
preservationScore: verification.preservationScore,
|
|
205
|
+
};
|
|
206
|
+
if (!verification.passed)
|
|
207
|
+
return { ...applied, receipt, status: 'needs_escalation', verification, deterministicArtifact };
|
|
208
|
+
const lifecycleBinding = createRebuildLifecycleBinding(task, receipt, deterministicArtifact);
|
|
209
|
+
return { ...applied, receipt, status: 'needs_semantic_review', verification, deterministicArtifact, lifecycleBinding };
|
|
210
|
+
}
|
|
211
|
+
export function createRebuildLifecycleBinding(task, receipt, deterministic) {
|
|
212
|
+
if (!deterministic.passed || receipt.taskFingerprint !== task.fingerprint || receipt.mode !== 'REBUILD' || deterministic.verificationKind !== 'rebuild') {
|
|
213
|
+
throw new Error('Lifecycle binding requires a passed rebuild artifact for this rebuild task.');
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
rewriteTaskFingerprint: task.fingerprint,
|
|
217
|
+
rewriteResponseFingerprint: receipt.responseFingerprint,
|
|
218
|
+
deterministicArtifactFingerprint: deterministic.artifactFingerprint,
|
|
219
|
+
sourceHash: deterministic.sourceHash,
|
|
220
|
+
candidateHash: deterministic.candidateHash,
|
|
221
|
+
profileId: deterministic.profileId,
|
|
222
|
+
profileRevisionDigest: deterministic.profileRevisionDigest,
|
|
223
|
+
rulesetVersion: deterministic.rulesetVersion,
|
|
224
|
+
schemaVersion: '1',
|
|
225
|
+
};
|
|
226
|
+
}
|