@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
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
import { EvaluationContractError, commitProtocol, freezeBlind, recordCheckpointDisposition, recordRating, reduceEvaluation, sealRatings, sha256Canonical, validateRuns, } from './stage1-evaluation.js';
|
|
5
|
+
const hex = (character) => character.repeat(64);
|
|
6
|
+
function protocol(mode = 'development-calibration') {
|
|
7
|
+
const providerDisclosureDigest = hex('f');
|
|
8
|
+
const reviewerDisclosureDigest = hex('8');
|
|
9
|
+
const derivedRetentionDigest = hex('9');
|
|
10
|
+
const caseIdentity = { caseId: 'case-1', caseDigest: hex('3'), provenanceDigest: hex('6'), providerDisclosureDigest, reviewerDisclosureDigest, derivedRetentionDigest };
|
|
11
|
+
const nonce = 'AAAAAAAAAAAAAAAAAAAAAA';
|
|
12
|
+
return {
|
|
13
|
+
kind: 'hyv-stage1-preregistration', version: '1', mode,
|
|
14
|
+
baseline: { packageVersion: '3.2.0', sourceCommit: '4e6269121d551c008a34db73077e1e4fea41b3f9' },
|
|
15
|
+
stage1: { sourceCommit: '550ea24f652291dca13757fdbd2f0fa0b5e3f621' },
|
|
16
|
+
benchmark: { manifestDigest: hex('1'), partitionDigest: hex('2'), partition: mode === 'locked-human' ? 'locked-test' : 'calibration', synthetic: mode !== 'locked-human' },
|
|
17
|
+
cases: [{ ...caseIdentity, rightsDigest: sha256Canonical(caseIdentity) }],
|
|
18
|
+
intentToTreat: { expectedAssignments: 2, expectedReviewers: 1 },
|
|
19
|
+
execution: { provider: 'provider-neutral', model: 'pinned-model', modelRevision: 'immutable-revision-1', settingsDigest: hex('a'), taskContractDigest: hex('b'), rulesetDigest: hex('c'), rubricDigest: hex('d') },
|
|
20
|
+
randomization: { algorithm: 'sha256-counter-v1', commitment: sha256Canonical({ algorithm: 'sha256-counter-v1', nonce, labels: { A: 'stage1', B: 'baseline' } }) },
|
|
21
|
+
reviewerRosterDigest: hex('5'),
|
|
22
|
+
analysis: { statistic: 'paired-preference-rate', decisionRule: 'preference-or-correction-with-workflow-non-regression', margin: 0.05, minimumCases: 1, minimumRatings: 1, missingRatingPolicy: 'count-as-non-preference', tiePolicy: 'count-as-half', retryPolicy: 'no-retry', routingPolicy: 'precommitted-blind-routing' },
|
|
23
|
+
rights: { providerDisclosureDigest, reviewerDisclosureDigest, derivedRetentionDigest },
|
|
24
|
+
releaseAuditContractDigest: hex('7'),
|
|
25
|
+
registeredAt: '2026-08-13T00:00:00Z',
|
|
26
|
+
measures: ['writer_preference', 'correction_versus_confirm', 'workflow_completion', 'workflow_abandonment'],
|
|
27
|
+
gates: ['semantic', 'copy_spec', 'hygiene', 'preservation', 'cli_mcp_parity', 'backward_compatibility'],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function rating(state, evidenceClass = 'synthetic-dry-run', previous = []) {
|
|
31
|
+
const reviewerId = 'reviewer-1';
|
|
32
|
+
const identityKey = sha256Canonical({ reviewerId });
|
|
33
|
+
const core = {
|
|
34
|
+
kind: 'hyv-stage1-reviewer-record', version: '1', evidenceClass,
|
|
35
|
+
protocolDigest: state.committed.protocolDigest, candidateDigest: state.candidateDigest, packetDigest: state.packet.packetDigest,
|
|
36
|
+
reviewerId, identityKey, recordId: sha256Canonical({ protocolDigest: state.committed.protocolDigest, packetDigest: state.packet.packetDigest, identityKey, caseId: 'case-1' }),
|
|
37
|
+
sequence: previous.length + 1, previousRecordDigest: previous.length ? previous.at(-1).recordDigest : null,
|
|
38
|
+
caseId: 'case-1', preferredLabel: 'A', correctionVersusConfirm: { A: 'confirm', B: 'correction' }, workflow: 'completed',
|
|
39
|
+
};
|
|
40
|
+
return { ...core, recordDigest: sha256Canonical(core), attestation: null };
|
|
41
|
+
}
|
|
42
|
+
function setup(mode = 'development-calibration') {
|
|
43
|
+
const committed = commitProtocol(protocol(mode));
|
|
44
|
+
const candidateDigest = sha256Canonical({ baseline: committed.baseline, stage1: committed.stage1 });
|
|
45
|
+
const runs = ['baseline', 'stage1'].map((arm, ordinal) => ({
|
|
46
|
+
kind: 'hyv-stage1-run-record', version: '1', evidenceClass: mode === 'locked-human' ? 'human' : 'synthetic-dry-run',
|
|
47
|
+
protocolDigest: committed.protocolDigest, candidateDigest, assignmentId: `assignment-${arm}`,
|
|
48
|
+
participantId: 'participant-1', caseId: 'case-1', caseDigest: hex('3'), rightsDigest: committed.cases[0].rightsDigest, arm,
|
|
49
|
+
ordinal: ordinal + 1, latencyMs: 10, inputTokens: 5, outputTokens: 5, costMicrousd: 1,
|
|
50
|
+
occurredAt: `2026-08-13T00:00:0${ordinal}Z`,
|
|
51
|
+
outcome: 'completed', outputDigest: createHash('sha256').update(arm === 'baseline' ? 'candidate B text' : 'candidate A text').digest('hex'),
|
|
52
|
+
hardGates: { semantic: true, copySpec: true, hygiene: true, preservation: true, cliMcpParity: true, backwardCompatibility: true },
|
|
53
|
+
}));
|
|
54
|
+
const mapping = { kind: 'hyv-stage1-blind-mapping', version: '1', nonce: 'AAAAAAAAAAAAAAAAAAAAAA', labels: { A: 'stage1', B: 'baseline' }, custodianId: 'dry-run-custodian', unblindingAccess: ['dry-run-custodian'], attestation: null };
|
|
55
|
+
const frozen = freezeBlind(committed, runs, mapping, [{ caseId: 'case-1', A: 'candidate A text', B: 'candidate B text' }]);
|
|
56
|
+
return { committed, candidateDigest, runs, mapping, ...frozen };
|
|
57
|
+
}
|
|
58
|
+
test('canonical preregistration digest is stable and identities are immutable', () => {
|
|
59
|
+
assert.equal(sha256Canonical({ b: 2, a: 1 }), sha256Canonical({ a: 1, b: 2 }));
|
|
60
|
+
const committed = commitProtocol(protocol());
|
|
61
|
+
assert.match(committed.protocolDigest, /^[a-f0-9]{64}$/);
|
|
62
|
+
assert.throws(() => commitProtocol({ ...protocol(), stage1: { sourceCommit: hex('a') } }), (error) => error instanceof EvaluationContractError && error.code === 'stage1_identity_mismatch');
|
|
63
|
+
assert.throws(() => commitProtocol({ ...protocol(), extra: true }), (error) => error instanceof EvaluationContractError && error.code === 'unexpected_field');
|
|
64
|
+
assert.throws(() => commitProtocol({ ...protocol('locked-human'), benchmark: { ...protocol('locked-human').benchmark, partition: 'calibration' } }), (error) => error instanceof EvaluationContractError && error.code === 'benchmark_partition_mismatch');
|
|
65
|
+
});
|
|
66
|
+
test('run validation binds every digest and reconciles the intent-to-treat denominator', () => {
|
|
67
|
+
const state = setup();
|
|
68
|
+
assert.equal(validateRuns(state.committed, state.runs).denominator, 2);
|
|
69
|
+
assert.throws(() => validateRuns(state.committed, state.runs.slice(1)), (error) => error instanceof EvaluationContractError && error.code === 'itt_denominator_mismatch');
|
|
70
|
+
assert.throws(() => validateRuns(state.committed, [{ ...state.runs[0], rightsDigest: hex('9') }, state.runs[1]]), (error) => error instanceof EvaluationContractError && error.code === 'rights_digest_mismatch');
|
|
71
|
+
const { outputDigest: _outputDigest, hardGates: _hardGates, ...failed } = state.runs[0];
|
|
72
|
+
assert.equal(validateRuns(state.committed, [{ ...failed, outcome: 'timeout' }, state.runs[1]]).timeouts, 1);
|
|
73
|
+
});
|
|
74
|
+
test('preregistration and paired assignments reject the reviewed integrity attacks', () => {
|
|
75
|
+
const base = protocol();
|
|
76
|
+
assert.throws(() => commitProtocol({ ...base, registeredAt: '1' }), (error) => error instanceof EvaluationContractError && error.code === 'registered_at_invalid');
|
|
77
|
+
assert.throws(() => commitProtocol({ ...base, registeredAt: '2026-02-31T00:00:00Z' }), (error) => error instanceof EvaluationContractError && error.code === 'registered_at_invalid');
|
|
78
|
+
assert.throws(() => commitProtocol({ ...base, cases: [{ ...base.cases[0], rightsDigest: hex('4') }] }), (error) => error instanceof EvaluationContractError && error.code === 'case_rights_digest_mismatch');
|
|
79
|
+
const state = setup();
|
|
80
|
+
assert.throws(() => validateRuns(state.committed, [state.runs[0], { ...state.runs[1], ordinal: 1 }]), (error) => error instanceof EvaluationContractError && error.code === 'ordinal_sequence_invalid');
|
|
81
|
+
assert.throws(() => validateRuns(state.committed, [state.runs[0], { ...state.runs[1], participantId: 'participant-2' }]), (error) => error instanceof EvaluationContractError && error.code === 'paired_participant_mismatch');
|
|
82
|
+
assert.throws(() => freezeBlind(state.committed, state.runs, { ...state.mapping, nonce: 'short' }, [{ caseId: 'case-1', A: 'candidate A text', B: 'candidate B text' }]), (error) => error instanceof EvaluationContractError && error.code === 'randomization_nonce_invalid');
|
|
83
|
+
assert.throws(() => freezeBlind(state.committed, state.runs, { ...state.mapping, unblindingAccess: [] }, [{ caseId: 'case-1', A: 'candidate A text', B: 'candidate B text' }]), (error) => error instanceof EvaluationContractError && error.code === 'unblinding_access_invalid');
|
|
84
|
+
});
|
|
85
|
+
test('blind packet rejects label leaks and mapping swaps', () => {
|
|
86
|
+
const state = setup();
|
|
87
|
+
const { attestation: _attestation, ...mappingCore } = state.mapping;
|
|
88
|
+
assert.equal(state.packet.mappingDigest, sha256Canonical(mappingCore));
|
|
89
|
+
assert.throws(() => freezeBlind(state.committed, state.runs, state.mapping, [{ caseId: 'case-1', A: 'stage1 output', B: 'safe' }]), (error) => error instanceof EvaluationContractError && error.code === 'blind_label_leak');
|
|
90
|
+
assert.throws(() => sealRatings(state.packet, { ...state.mapping, labels: { A: 'baseline', B: 'stage1' } }, []), (error) => error instanceof EvaluationContractError && error.code === 'mapping_digest_mismatch');
|
|
91
|
+
});
|
|
92
|
+
test('reviewer log is append-only and rejects duplicate or conflicting ratings', () => {
|
|
93
|
+
const state = setup();
|
|
94
|
+
const record = rating(state);
|
|
95
|
+
const records = recordRating(state.packet, [], record);
|
|
96
|
+
assert.equal(records.length, 1);
|
|
97
|
+
assert.throws(() => recordRating(state.packet, records, record), (error) => error instanceof EvaluationContractError && error.code === 'reviewer_chain_invalid' || error instanceof EvaluationContractError && error.code === 'duplicate_reviewer_record');
|
|
98
|
+
const changedCore = { ...record, preferredLabel: 'B', sequence: 2, previousRecordDigest: record.recordDigest, recordDigest: undefined, attestation: undefined };
|
|
99
|
+
delete changedCore.recordDigest;
|
|
100
|
+
delete changedCore.attestation;
|
|
101
|
+
assert.throws(() => recordRating(state.packet, records, { ...changedCore, recordDigest: sha256Canonical(changedCore), attestation: null }), (error) => error instanceof EvaluationContractError && error.code === 'conflicting_reviewer_record');
|
|
102
|
+
});
|
|
103
|
+
test('development/calibration reduction is always blocked and non-promotable', () => {
|
|
104
|
+
const state = setup();
|
|
105
|
+
const records = recordRating(state.packet, [], rating(state));
|
|
106
|
+
const seal = sealRatings(state.packet, state.mapping, records);
|
|
107
|
+
const auditCore = { kind: 'hyv-release-audit', version: '1', candidateCommit: state.committed.stage1.sourceCommit, protocolDigest: state.committed.protocolDigest, contractDigest: state.committed.releaseAuditContractDigest, passed: true };
|
|
108
|
+
const releaseAudit = { ...auditCore, digest: sha256Canonical(auditCore) };
|
|
109
|
+
const report = reduceEvaluation(state.committed, state.runs, state.packet, state.mapping, records, seal, releaseAudit);
|
|
110
|
+
assert.equal(report.decision, 'BLOCKED');
|
|
111
|
+
assert.equal(report.promotable, false);
|
|
112
|
+
assert.ok(report.blockers.includes('synthetic_fixture_evidence'));
|
|
113
|
+
assert.ok(report.blockers.includes('locked_human_evidence_required'));
|
|
114
|
+
assert.ok(report.blockers.includes('human_writer_evidence_deferred'));
|
|
115
|
+
});
|
|
116
|
+
test('locked-human evidence rejects missing external signatures and release-audit drift', () => {
|
|
117
|
+
const state = setup('locked-human');
|
|
118
|
+
const unsigned = rating(state, 'human');
|
|
119
|
+
assert.throws(() => recordRating(state.packet, [], unsigned), (error) => error instanceof EvaluationContractError && error.code === 'human_attestation_required');
|
|
120
|
+
const signed = { ...unsigned, attestation: { kind: 'external-verification-receipt', version: '1', verified: true, artifactDigest: unsigned.recordDigest, trustStoreDigest: hex('a'), keyId: 'human-key-1', purpose: 'stage1-blind-review', protocolDigest: state.committed.protocolDigest, packetDigest: state.packet.packetDigest, nonce: 'nonce-1', issuedAt: '2026-08-13T00:00:00Z', expiresAt: '2026-08-14T00:00:00Z', verifiedAt: '2026-08-13T00:01:00Z' } };
|
|
121
|
+
const records = recordRating(state.packet, [], signed);
|
|
122
|
+
const seal = sealRatings(state.packet, state.mapping, records);
|
|
123
|
+
assert.throws(() => reduceEvaluation(state.committed, state.runs, state.packet, state.mapping, records, seal, { kind: 'hyv-release-audit', version: '1', candidateCommit: hex('8'), protocolDigest: state.committed.protocolDigest, contractDigest: state.committed.releaseAuditContractDigest, passed: true, digest: hex('7') }), (error) => error instanceof EvaluationContractError && error.code === 'release_audit_digest_mismatch');
|
|
124
|
+
});
|
|
125
|
+
test('forged locked-human JSON cannot pass or proceed while human evidence is deferred', () => {
|
|
126
|
+
const state = setup();
|
|
127
|
+
const records = recordRating(state.packet, [], rating(state));
|
|
128
|
+
const seal = sealRatings(state.packet, state.mapping, records);
|
|
129
|
+
const auditCore = { kind: 'hyv-release-audit', version: '1', candidateCommit: state.committed.stage1.sourceCommit, protocolDigest: state.committed.protocolDigest, contractDigest: state.committed.releaseAuditContractDigest, passed: true };
|
|
130
|
+
const report = reduceEvaluation(state.committed, state.runs, state.packet, state.mapping, records, seal, { ...auditCore, digest: sha256Canonical(auditCore) });
|
|
131
|
+
const fake = { ...report, decision: 'PASS', promotable: true, blockers: [] };
|
|
132
|
+
const { reportDigest: _old, ...core } = fake;
|
|
133
|
+
const forged = { ...core, reportDigest: sha256Canonical(core) };
|
|
134
|
+
assert.throws(() => recordCheckpointDisposition(forged, 'PROCEED_TO_MAR_363', {}), (error) => error instanceof EvaluationContractError && error.code === 'report_must_be_blocked');
|
|
135
|
+
assert.throws(() => recordCheckpointDisposition(forged, 'STOP', {}), (error) => error instanceof EvaluationContractError && error.code === 'report_must_be_blocked');
|
|
136
|
+
assert.throws(() => recordCheckpointDisposition(forged, 'REPEAT_PROTOCOL', {}), (error) => error instanceof EvaluationContractError && error.code === 'report_must_be_blocked');
|
|
137
|
+
const emptyNestedCore = { ...report, intentToTreat: {}, metrics: {}, reportDigest: undefined };
|
|
138
|
+
delete emptyNestedCore.reportDigest;
|
|
139
|
+
const emptyNested = { ...emptyNestedCore, reportDigest: sha256Canonical(emptyNestedCore) };
|
|
140
|
+
assert.throws(() => recordCheckpointDisposition(emptyNested, 'STOP', {}), (error) => error instanceof EvaluationContractError && error.code === 'missing_field');
|
|
141
|
+
});
|
|
142
|
+
test('a failed arm is committed as non-reviewable and remains in the ITT report', () => {
|
|
143
|
+
const state = setup();
|
|
144
|
+
const { outputDigest: _output, hardGates: _gates, ...stage1Failure } = state.runs[1];
|
|
145
|
+
const runs = [state.runs[0], { ...stage1Failure, outcome: 'timeout' }];
|
|
146
|
+
const { packet } = freezeBlind(state.committed, runs, state.mapping, [], [{ caseId: 'case-1', baselineOutcome: 'completed', stage1Outcome: 'timeout' }]);
|
|
147
|
+
assert.equal(packet.reviewableCount, 0);
|
|
148
|
+
assert.equal(packet.nonReviewableCount, 1);
|
|
149
|
+
const abandonedCore = { ...rating({ ...state, packet }), workflow: 'abandoned', preferredLabel: undefined, correctionVersusConfirm: undefined, recordDigest: undefined, attestation: undefined };
|
|
150
|
+
delete abandonedCore.preferredLabel;
|
|
151
|
+
delete abandonedCore.correctionVersusConfirm;
|
|
152
|
+
delete abandonedCore.recordDigest;
|
|
153
|
+
delete abandonedCore.attestation;
|
|
154
|
+
const abandoned = { ...abandonedCore, recordDigest: sha256Canonical(abandonedCore), attestation: null };
|
|
155
|
+
const ratings = recordRating(packet, [], abandoned);
|
|
156
|
+
const seal = sealRatings(packet, state.mapping, ratings);
|
|
157
|
+
const auditCore = { kind: 'hyv-release-audit', version: '1', candidateCommit: state.committed.stage1.sourceCommit, protocolDigest: state.committed.protocolDigest, contractDigest: state.committed.releaseAuditContractDigest, passed: true };
|
|
158
|
+
const report = reduceEvaluation(state.committed, runs, packet, state.mapping, ratings, seal, { ...auditCore, digest: sha256Canonical(auditCore) });
|
|
159
|
+
assert.equal(report.decision, 'BLOCKED');
|
|
160
|
+
assert.equal(report.metrics.preference.denominator, 1);
|
|
161
|
+
assert.equal(report.metrics.preference.numerator, 0);
|
|
162
|
+
assert.equal(report.metrics.abandonment.numerator, 1);
|
|
163
|
+
assert.equal(report.metrics.correctionVersusConfirm.stage1.corrections, 0);
|
|
164
|
+
assert.equal(report.metrics.correctionVersusConfirm.baseline.corrections, 0);
|
|
165
|
+
assert.equal(report.metrics.providerRuns.timeouts, 1);
|
|
166
|
+
});
|
|
167
|
+
test('failed hard gates never enter blind review and packet counts reconcile at reduction', () => {
|
|
168
|
+
const state = setup();
|
|
169
|
+
const runs = [state.runs[0], { ...state.runs[1], hardGates: { ...state.runs[1].hardGates, semantic: false } }];
|
|
170
|
+
const { packet } = freezeBlind(state.committed, runs, state.mapping, [], [{ caseId: 'case-1', baselineOutcome: 'completed', stage1Outcome: 'hard-gate-failed' }]);
|
|
171
|
+
assert.equal(packet.reviewableCount, 0);
|
|
172
|
+
const abandonedCore = { ...rating({ ...state, packet }), workflow: 'abandoned', preferredLabel: undefined, correctionVersusConfirm: undefined, recordDigest: undefined, attestation: undefined };
|
|
173
|
+
delete abandonedCore.preferredLabel;
|
|
174
|
+
delete abandonedCore.correctionVersusConfirm;
|
|
175
|
+
delete abandonedCore.recordDigest;
|
|
176
|
+
delete abandonedCore.attestation;
|
|
177
|
+
const ratings = recordRating(packet, [], { ...abandonedCore, recordDigest: sha256Canonical(abandonedCore), attestation: null });
|
|
178
|
+
const seal = sealRatings(packet, state.mapping, ratings);
|
|
179
|
+
const auditCore = { kind: 'hyv-release-audit', version: '1', candidateCommit: state.committed.stage1.sourceCommit, protocolDigest: state.committed.protocolDigest, contractDigest: state.committed.releaseAuditContractDigest, passed: true };
|
|
180
|
+
const forgedPacketCore = { ...packet, reviewableCount: 1, nonReviewableCount: 0, packetDigest: undefined };
|
|
181
|
+
delete forgedPacketCore.packetDigest;
|
|
182
|
+
const forgedPacket = { ...forgedPacketCore, packetDigest: sha256Canonical(forgedPacketCore) };
|
|
183
|
+
assert.throws(() => reduceEvaluation(state.committed, runs, forgedPacket, state.mapping, ratings, seal, { ...auditCore, digest: sha256Canonical(auditCore) }), (error) => error instanceof EvaluationContractError && error.code === 'blind_packet_count_mismatch');
|
|
184
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, symlinkSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
import { BASELINE_COMMIT, STAGE1_COMMIT } from './stage1-evaluation.js';
|
|
8
|
+
function gitObjectExists(commit) {
|
|
9
|
+
try {
|
|
10
|
+
execFileSync('git', ['cat-file', '-t', commit], { stdio: 'pipe' });
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function packetFiles(root) {
|
|
18
|
+
return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
|
|
19
|
+
const path = join(root, entry.name);
|
|
20
|
+
return entry.isDirectory() ? packetFiles(path) : [path];
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
test('Stage 1 human packet executes outside the repository and remains blocked without ratings', () => {
|
|
24
|
+
const parent = mkdtempSync(join(tmpdir(), 'hyv-stage1-human-packet-test-'));
|
|
25
|
+
const outputRoot = join(parent, 'packet');
|
|
26
|
+
try {
|
|
27
|
+
const output = JSON.parse(execFileSync(process.execPath, ['scripts/run-stage1-human-packet.mjs', '--out', outputRoot], { encoding: 'utf8' }));
|
|
28
|
+
assert.equal(output.kind, 'hyv-stage1-operator-kit');
|
|
29
|
+
assert.equal(output.writerEvidence, false);
|
|
30
|
+
assert.equal(output.kitEmitted, true);
|
|
31
|
+
assert.equal(output.decision, 'BLOCKED');
|
|
32
|
+
assert.equal(output.promotable, false);
|
|
33
|
+
assert.ok(output.blockers.includes('human_writer_evidence_deferred'));
|
|
34
|
+
assert.ok(output.blockers.includes('locked_human_evidence_required'));
|
|
35
|
+
assert.equal(output.blockers.includes('stage1_commit_uncheckoutable'), !gitObjectExists(STAGE1_COMMIT));
|
|
36
|
+
assert.ok(output.blockers.includes('receipts_unverified'));
|
|
37
|
+
assert.equal(existsSync(join(outputRoot, 'ratings.ndjson')), false);
|
|
38
|
+
for (const file of packetFiles(outputRoot)) {
|
|
39
|
+
assert.doesNotMatch(readFileSync(file, 'utf8'), /"evidenceClass"\s*:\s*"human"/);
|
|
40
|
+
}
|
|
41
|
+
const status = JSON.parse(readFileSync(join(outputRoot, 'status.json'), 'utf8'));
|
|
42
|
+
assert.equal(status.kind, 'hyv-stage1-operator-kit');
|
|
43
|
+
assert.equal(status.writerEvidence, false);
|
|
44
|
+
assert.equal(status.decision, output.decision);
|
|
45
|
+
assert.equal(status.promotable, output.promotable);
|
|
46
|
+
assert.deepEqual(status.blockers, output.blockers);
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
rmSync(parent, { recursive: true, force: true });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
test('Stage 1 human packet reports locked commit checkoutability', () => {
|
|
53
|
+
const parent = mkdtempSync(join(tmpdir(), 'hyv-stage1-human-identities-test-'));
|
|
54
|
+
const outputRoot = join(parent, 'packet');
|
|
55
|
+
try {
|
|
56
|
+
execFileSync(process.execPath, ['scripts/run-stage1-human-packet.mjs', '--out', outputRoot], { stdio: 'pipe' });
|
|
57
|
+
const identities = JSON.parse(readFileSync(join(outputRoot, 'IDENTITIES.json'), 'utf8'));
|
|
58
|
+
assert.equal(identities.baselineCheckoutable, gitObjectExists(BASELINE_COMMIT));
|
|
59
|
+
assert.equal(identities.stage1CommitCheckoutable, gitObjectExists(STAGE1_COMMIT));
|
|
60
|
+
assert.equal(identities.exactHeadBindingSatisfied, identities.stage1CommitCheckoutable);
|
|
61
|
+
assert.equal(identities.captureAuthorized, false);
|
|
62
|
+
const operator = readFileSync(join(outputRoot, 'OPERATOR.md'), 'utf8');
|
|
63
|
+
assert.match(operator, /Do not capture paired Stage 1 arm output until/);
|
|
64
|
+
assert.match(operator, /verificationStatus: unverified/);
|
|
65
|
+
const rating = JSON.parse(readFileSync(join(outputRoot, 'examples', 'rating.json'), 'utf8'));
|
|
66
|
+
assert.equal(rating.verificationStatus, 'unverified');
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
rmSync(parent, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
test('Stage 1 human packet refuses a relative output path', () => {
|
|
73
|
+
assert.throws(() => execFileSync(process.execPath, ['scripts/run-stage1-human-packet.mjs', '--out', 'stage1-human-output'], { stdio: 'pipe' }), /Human packet output must use an absolute path/);
|
|
74
|
+
});
|
|
75
|
+
test('Stage 1 human packet refuses repository output', () => {
|
|
76
|
+
assert.throws(() => execFileSync(process.execPath, ['scripts/run-stage1-human-packet.mjs', '--out', join(process.cwd(), 'stage1-human-output')], { stdio: 'pipe' }), /Human packet output must stay outside the repository and every worktree/);
|
|
77
|
+
});
|
|
78
|
+
test('Stage 1 human packet refuses every linked worktree and the primary clone', () => {
|
|
79
|
+
const porcelain = execFileSync('git', ['worktree', 'list', '--porcelain'], { encoding: 'utf8' });
|
|
80
|
+
const trees = porcelain.split('\n').flatMap((line) => (line.startsWith('worktree ') ? [realpathSync(line.slice('worktree '.length))] : []));
|
|
81
|
+
const gitCommonDir = realpathSync(execFileSync('git', ['rev-parse', '--git-common-dir'], { encoding: 'utf8' }).trim());
|
|
82
|
+
trees.push(dirname(gitCommonDir));
|
|
83
|
+
for (const root of new Set(trees)) {
|
|
84
|
+
assert.throws(() => execFileSync(process.execPath, ['scripts/run-stage1-human-packet.mjs', '--out', join(root, 'stage1-human-output')], { stdio: 'pipe' }), /Human packet output must stay outside the repository and every worktree/);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
test('Stage 1 human packet refuses a symlinked ancestor into the repository', () => {
|
|
88
|
+
const parent = mkdtempSync(join(tmpdir(), 'hyv-stage1-human-symlink-test-'));
|
|
89
|
+
const repositoryTarget = join(process.cwd(), 'stage1-human-symlink-target');
|
|
90
|
+
mkdirSync(repositoryTarget);
|
|
91
|
+
symlinkSync(repositoryTarget, join(parent, 'redirect'));
|
|
92
|
+
try {
|
|
93
|
+
assert.throws(() => execFileSync(process.execPath, ['scripts/run-stage1-human-packet.mjs', '--out', join(parent, 'redirect', 'packet')], { stdio: 'pipe' }), /Human packet output must stay outside the repository and every worktree/);
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
rmSync(parent, { recursive: true, force: true });
|
|
97
|
+
rmSync(repositoryTarget, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
test('Stage 1 human packet refuses fabricated human ratings', () => {
|
|
101
|
+
assert.throws(() => execFileSync(process.execPath, ['scripts/run-stage1-human-packet.mjs', '--fabricate-human-ratings'], { stdio: 'pipe' }), /Human evidence cannot be fabricated/);
|
|
102
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
const schemaRoot = join(process.cwd(), 'benchmarks/schema');
|
|
6
|
+
const schemaNames = [
|
|
7
|
+
'protocol-manifest.v1',
|
|
8
|
+
'run-event.v1',
|
|
9
|
+
'blind-packet.v1',
|
|
10
|
+
'blind-mapping.v1',
|
|
11
|
+
'reviewer-record.v1',
|
|
12
|
+
'ratings-seal.v1',
|
|
13
|
+
'aggregate-report.v1',
|
|
14
|
+
'checkpoint-disposition.v1',
|
|
15
|
+
];
|
|
16
|
+
function loadSchema(name) {
|
|
17
|
+
return JSON.parse(readFileSync(join(schemaRoot, `${name}.schema.json`), 'utf8'));
|
|
18
|
+
}
|
|
19
|
+
function assertWireKeys(name, keys) {
|
|
20
|
+
const schema = loadSchema(name);
|
|
21
|
+
const optionalKeys = name === 'run-event.v1'
|
|
22
|
+
? ['outputDigest', 'hardGates']
|
|
23
|
+
: name === 'reviewer-record.v1' ? ['preferredLabel', 'correctionVersusConfirm'] : [];
|
|
24
|
+
assert.deepEqual(Object.keys(schema.properties).sort(), [...keys].sort(), `${name} wire keys drifted from the evaluator`);
|
|
25
|
+
assert.deepEqual([...schema.required].sort(), [...keys.filter((key) => !optionalKeys.includes(key))].sort(), `${name} required keys drifted from the evaluator`);
|
|
26
|
+
}
|
|
27
|
+
test('Stage 1 evidence schemas are strict Draft 2020-12 contracts', () => {
|
|
28
|
+
for (const name of schemaNames) {
|
|
29
|
+
const schema = loadSchema(name);
|
|
30
|
+
assert.equal(schema.$schema, 'https://json-schema.org/draft/2020-12/schema');
|
|
31
|
+
assert.equal(schema.$id, `https://holdyourvoice.dev/schemas/${name}.schema.json`);
|
|
32
|
+
assert.equal(schema.type, 'object');
|
|
33
|
+
assert.equal(schema.additionalProperties, false);
|
|
34
|
+
assert.ok(Array.isArray(schema.required) && schema.required.length > 0);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
test('protocol and evidence schemas bind immutable identities and the complete digest chain', () => {
|
|
38
|
+
const protocol = loadSchema('protocol-manifest.v1');
|
|
39
|
+
assert.equal(protocol.properties.baseline.properties.sourceCommit.const, '4e6269121d551c008a34db73077e1e4fea41b3f9');
|
|
40
|
+
assert.equal(protocol.properties.stage1.properties.sourceCommit.const, '550ea24f652291dca13757fdbd2f0fa0b5e3f621');
|
|
41
|
+
for (const field of ['kind', 'version', 'mode', 'benchmark', 'cases', 'execution', 'reviewerRosterDigest', 'analysis', 'rights', 'releaseAuditContractDigest', 'protocolDigest']) {
|
|
42
|
+
assert.ok(protocol.required.includes(field), `protocol must require ${field}`);
|
|
43
|
+
}
|
|
44
|
+
const run = loadSchema('run-event.v1');
|
|
45
|
+
for (const field of ['protocolDigest', 'candidateDigest', 'caseDigest', 'rightsDigest', 'ordinal', 'latencyMs', 'inputTokens', 'outputTokens', 'costMicrousd'])
|
|
46
|
+
assert.ok(run.required.includes(field));
|
|
47
|
+
assert.ok(run.oneOf[0].required.includes('outputDigest'), 'completed runs must bind the output digest');
|
|
48
|
+
assert.match(JSON.stringify(run.oneOf[1].not), /outputDigest/, 'failed runs must not fabricate an output digest');
|
|
49
|
+
const packet = loadSchema('blind-packet.v1');
|
|
50
|
+
for (const field of ['protocolDigest', 'candidateDigest', 'runsDigest', 'packetDigest', 'mappingDigest', 'contentDigest', 'nonReviewableDigest', 'reviewableCount', 'nonReviewableCount'])
|
|
51
|
+
assert.ok(packet.required.includes(field));
|
|
52
|
+
const reviewer = loadSchema('reviewer-record.v1');
|
|
53
|
+
for (const field of ['protocolDigest', 'candidateDigest', 'packetDigest', 'reviewerId', 'identityKey', 'recordId', 'sequence', 'previousRecordDigest', 'recordDigest', 'caseId', 'attestation'])
|
|
54
|
+
assert.ok(reviewer.required.includes(field));
|
|
55
|
+
assert.equal(reviewer.properties.sourceText, undefined);
|
|
56
|
+
assert.equal(reviewer.properties.correctionText, undefined);
|
|
57
|
+
const seal = loadSchema('ratings-seal.v1');
|
|
58
|
+
for (const field of ['protocolDigest', 'candidateDigest', 'packetDigest', 'mappingDigest', 'ratingsDigest', 'sealDigest'])
|
|
59
|
+
assert.ok(seal.required.includes(field));
|
|
60
|
+
const report = loadSchema('aggregate-report.v1');
|
|
61
|
+
for (const field of ['protocolDigest', 'candidateDigest', 'runsDigest', 'packetDigest', 'mappingDigest', 'sealDigest', 'releaseAuditDigest', 'intentToTreat', 'metrics'])
|
|
62
|
+
assert.ok(report.required.includes(field));
|
|
63
|
+
});
|
|
64
|
+
test('deferred-human artifacts cannot claim pass, promotion, or MAR-363 progression', () => {
|
|
65
|
+
const report = loadSchema('aggregate-report.v1');
|
|
66
|
+
assert.equal(report.properties.decision.const, 'BLOCKED');
|
|
67
|
+
assert.equal(report.properties.promotable.const, false);
|
|
68
|
+
assert.equal(report.properties.blockers.contains.const, 'human_writer_evidence_deferred');
|
|
69
|
+
const disposition = loadSchema('checkpoint-disposition.v1');
|
|
70
|
+
assert.deepEqual(disposition.properties.disposition.enum, ['STOP', 'REPEAT_PROTOCOL']);
|
|
71
|
+
assert.equal(disposition.properties.releaseApproved, undefined, 'checkpoint disposition is not a release approval');
|
|
72
|
+
});
|
|
73
|
+
test('schema wire keys match evaluator artifacts exactly', () => {
|
|
74
|
+
assertWireKeys('protocol-manifest.v1', ['kind', 'version', 'mode', 'baseline', 'stage1', 'benchmark', 'cases', 'intentToTreat', 'execution', 'randomization', 'reviewerRosterDigest', 'analysis', 'rights', 'releaseAuditContractDigest', 'registeredAt', 'measures', 'gates', 'protocolDigest']);
|
|
75
|
+
assertWireKeys('run-event.v1', ['kind', 'version', 'evidenceClass', 'protocolDigest', 'candidateDigest', 'assignmentId', 'participantId', 'caseId', 'caseDigest', 'rightsDigest', 'arm', 'ordinal', 'latencyMs', 'inputTokens', 'outputTokens', 'costMicrousd', 'occurredAt', 'outcome', 'outputDigest', 'hardGates']);
|
|
76
|
+
assertWireKeys('blind-packet.v1', ['kind', 'version', 'protocolDigest', 'candidateDigest', 'runsDigest', 'mappingDigest', 'contentDigest', 'nonReviewableDigest', 'reviewableCount', 'nonReviewableCount', 'encryptedAtRest', 'approvedStorage', 'packetDigest']);
|
|
77
|
+
assertWireKeys('blind-mapping.v1', ['kind', 'version', 'nonce', 'labels', 'custodianId', 'unblindingAccess', 'attestation']);
|
|
78
|
+
assertWireKeys('reviewer-record.v1', ['kind', 'version', 'evidenceClass', 'protocolDigest', 'candidateDigest', 'packetDigest', 'reviewerId', 'identityKey', 'recordId', 'sequence', 'previousRecordDigest', 'recordDigest', 'caseId', 'preferredLabel', 'correctionVersusConfirm', 'workflow', 'attestation']);
|
|
79
|
+
assertWireKeys('ratings-seal.v1', ['kind', 'version', 'protocolDigest', 'candidateDigest', 'packetDigest', 'mappingDigest', 'ratingsDigest', 'recordCount', 'sealDigest']);
|
|
80
|
+
assertWireKeys('aggregate-report.v1', ['kind', 'version', 'protocolDigest', 'candidateDigest', 'runsDigest', 'packetDigest', 'mappingDigest', 'sealDigest', 'releaseAuditDigest', 'intentToTreat', 'metrics', 'decision', 'promotable', 'blockers', 'reportDigest']);
|
|
81
|
+
assertWireKeys('checkpoint-disposition.v1', ['kind', 'version', 'protocolDigest', 'packetDigest', 'releaseAuditDigest', 'reportDigest', 'disposition', 'attestation', 'dispositionDigest']);
|
|
82
|
+
});
|
|
83
|
+
test('nested protocol and report contracts match the repaired runtime', () => {
|
|
84
|
+
const protocol = loadSchema('protocol-manifest.v1');
|
|
85
|
+
assert.deepEqual([...protocol.properties.cases.items.required].sort(), ['caseId', 'caseDigest', 'provenanceDigest', 'rightsDigest', 'providerDisclosureDigest', 'reviewerDisclosureDigest', 'derivedRetentionDigest'].sort());
|
|
86
|
+
assert.deepEqual([...protocol.properties.execution.required].sort(), ['provider', 'model', 'modelRevision', 'settingsDigest', 'taskContractDigest', 'rulesetDigest', 'rubricDigest'].sort());
|
|
87
|
+
const reviewer = loadSchema('reviewer-record.v1');
|
|
88
|
+
assert.deepEqual([...reviewer.properties.correctionVersusConfirm.required].sort(), ['A', 'B']);
|
|
89
|
+
assert.ok(!reviewer.required.includes('preferredLabel'));
|
|
90
|
+
assert.ok(!reviewer.required.includes('correctionVersusConfirm'));
|
|
91
|
+
assert.match(JSON.stringify(reviewer.allOf), /abandoned.*preferredLabel.*correctionVersusConfirm/);
|
|
92
|
+
const report = loadSchema('aggregate-report.v1');
|
|
93
|
+
assert.deepEqual([...report.properties.intentToTreat.required].sort(), ['denominator', 'completed', 'hardFailures', 'timeouts', 'abandonments', 'runsDigest', 'expectedRatings', 'observedRatings', 'missingRatings', 'reconciled'].sort());
|
|
94
|
+
assert.deepEqual([...report.properties.metrics.required].sort(), ['preference', 'correctionVersusConfirm', 'completion', 'abandonment', 'providerRuns'].sort());
|
|
95
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, symlinkSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
function packetFiles(root) {
|
|
8
|
+
return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
|
|
9
|
+
const path = join(root, entry.name);
|
|
10
|
+
return entry.isDirectory() ? packetFiles(path) : [path];
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
test('Stage 2 human packet executes outside the repository and remains blocked without ratings', () => {
|
|
14
|
+
const parent = mkdtempSync(join(tmpdir(), 'hyv-stage2-human-packet-test-'));
|
|
15
|
+
const outputRoot = join(parent, 'packet');
|
|
16
|
+
try {
|
|
17
|
+
const output = JSON.parse(execFileSync(process.execPath, ['scripts/run-stage2-human-packet.mjs', '--out', outputRoot], { encoding: 'utf8' }));
|
|
18
|
+
assert.equal(output.kind, 'hyv-stage2-operator-kit');
|
|
19
|
+
assert.equal(output.writerEvidence, false);
|
|
20
|
+
assert.equal(output.kitEmitted, true);
|
|
21
|
+
assert.equal(output.decision, 'BLOCKED');
|
|
22
|
+
assert.equal(output.promotable, false);
|
|
23
|
+
assert.ok(output.blockers.includes('stage2_adoption_evidence_deferred'));
|
|
24
|
+
assert.ok(output.blockers.includes('stage1_checkpoint_unpassed'));
|
|
25
|
+
assert.ok(output.blockers.includes('rebuild_release_claim_blocked'));
|
|
26
|
+
assert.equal(existsSync(join(outputRoot, 'ratings.ndjson')), false);
|
|
27
|
+
for (const file of packetFiles(outputRoot)) {
|
|
28
|
+
assert.doesNotMatch(readFileSync(file, 'utf8'), /"evidenceClass"\s*:\s*"human"/);
|
|
29
|
+
}
|
|
30
|
+
const status = JSON.parse(readFileSync(join(outputRoot, 'status.json'), 'utf8'));
|
|
31
|
+
assert.equal(status.kind, 'hyv-stage2-operator-kit');
|
|
32
|
+
assert.equal(status.decision, 'BLOCKED');
|
|
33
|
+
assert.equal(status.promotable, false);
|
|
34
|
+
assert.deepEqual(status.blockers, output.blockers);
|
|
35
|
+
const identities = JSON.parse(readFileSync(join(outputRoot, 'IDENTITIES.json'), 'utf8'));
|
|
36
|
+
assert.equal(identities.rebuildCommit, '387de69eae9ec176f32bfb0f3a7b769a4e0b6686');
|
|
37
|
+
const protocolSkeleton = JSON.parse(readFileSync(join(outputRoot, 'protocol-skeleton.json'), 'utf8'));
|
|
38
|
+
assert.equal(protocolSkeleton.rebuild.sourceCommit, identities.rebuildCommit);
|
|
39
|
+
assert.equal(protocolSkeleton.baseline.packageVersion, '3.2.0');
|
|
40
|
+
const operator = readFileSync(join(outputRoot, 'OPERATOR.md'), 'utf8');
|
|
41
|
+
assert.match(operator, /four separate reports/);
|
|
42
|
+
assert.match(operator, /Product publish does not wait on this kit/);
|
|
43
|
+
assert.match(operator, /Never record PROCEED_TO_MAR_365/);
|
|
44
|
+
assert.match(operator, /product npm publish may proceed without this kit/);
|
|
45
|
+
assert.match(operator, /do not claim writer checkpoints passed/);
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
rmSync(parent, { recursive: true, force: true });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
test('Stage 2 human packet refuses fabricated human ratings', () => {
|
|
52
|
+
assert.throws(() => execFileSync(process.execPath, ['scripts/run-stage2-human-packet.mjs', '--fabricate-human-ratings'], { stdio: 'pipe' }), /Human evidence cannot be fabricated/);
|
|
53
|
+
});
|
|
54
|
+
test('Stage 2 human packet refuses a relative output path', () => {
|
|
55
|
+
assert.throws(() => execFileSync(process.execPath, ['scripts/run-stage2-human-packet.mjs', '--out', 'stage2-human-output'], { stdio: 'pipe' }), /Human packet output must use an absolute path/);
|
|
56
|
+
});
|
|
57
|
+
test('Stage 2 human packet refuses repository output', () => {
|
|
58
|
+
assert.throws(() => execFileSync(process.execPath, ['scripts/run-stage2-human-packet.mjs', '--out', join(process.cwd(), 'stage2-human-output')], { stdio: 'pipe' }), /Human packet output must stay outside the repository and every worktree/);
|
|
59
|
+
});
|
|
60
|
+
test('Stage 2 human packet refuses every linked worktree and the primary clone', () => {
|
|
61
|
+
const porcelain = execFileSync('git', ['worktree', 'list', '--porcelain'], { encoding: 'utf8' });
|
|
62
|
+
const trees = porcelain.split('\n').flatMap((line) => (line.startsWith('worktree ') ? [realpathSync(line.slice('worktree '.length))] : []));
|
|
63
|
+
const gitCommonDir = realpathSync(execFileSync('git', ['rev-parse', '--git-common-dir'], { encoding: 'utf8' }).trim());
|
|
64
|
+
trees.push(dirname(gitCommonDir));
|
|
65
|
+
for (const root of new Set(trees)) {
|
|
66
|
+
assert.throws(() => execFileSync(process.execPath, ['scripts/run-stage2-human-packet.mjs', '--out', join(root, 'stage2-human-output')], { stdio: 'pipe' }), /Human packet output must stay outside the repository and every worktree/);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
test('Stage 2 human packet refuses a symlinked ancestor into the repository', () => {
|
|
70
|
+
const parent = mkdtempSync(join(tmpdir(), 'hyv-stage2-human-symlink-test-'));
|
|
71
|
+
const repositoryTarget = join(process.cwd(), 'stage2-human-symlink-target');
|
|
72
|
+
mkdirSync(repositoryTarget);
|
|
73
|
+
symlinkSync(repositoryTarget, join(parent, 'redirect'));
|
|
74
|
+
try {
|
|
75
|
+
assert.throws(() => execFileSync(process.execPath, ['scripts/run-stage2-human-packet.mjs', '--out', join(parent, 'redirect', 'packet')], { stdio: 'pipe' }), /Human packet output must stay outside the repository and every worktree/);
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
rmSync(parent, { recursive: true, force: true });
|
|
79
|
+
rmSync(repositoryTarget, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
});
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const HYV_VERSION = '3.
|
|
1
|
+
export const HYV_VERSION = '3.3.0';
|
package/dist/voice-dna.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { deviation, mean, paragraphs, sentences, words } from './text.js';
|
|
2
2
|
const STOP_WORDS = new Set(['the', 'and', 'that', 'with', 'this', 'from', 'your', 'have', 'were', 'they', 'will', 'into', 'about', 'what', 'when', 'where']);
|
|
3
3
|
const TRANSITIONS = ['but', 'because', 'instead', 'then', 'still', 'so', 'yet', 'therefore'];
|
|
4
|
+
const CONTRACTIONS = new Set([
|
|
5
|
+
"aren't", "can't", "couldn't", "didn't", "doesn't", "don't", "hadn't", "hasn't", "haven't", "he'd", "he'll", "he's",
|
|
6
|
+
"i'd", "i'll", "i'm", "i've", "isn't", "it'd", "it'll", "it's", "let's", "mightn't", "mustn't", "shan't", "she'd",
|
|
7
|
+
"she'll", "she's", "shouldn't", "that's", "there's", "they'd", "they'll", "they're", "they've", "wasn't", "we'd",
|
|
8
|
+
"we'll", "we're", "we've", "weren't", "what's", "where's", "who's", "won't", "wouldn't", "you'd", "you'll", "you're", "you've",
|
|
9
|
+
]);
|
|
4
10
|
function top(items, limit) {
|
|
5
11
|
const counts = new Map();
|
|
6
12
|
for (const item of items.filter(Boolean))
|
|
@@ -42,6 +48,36 @@ function profileMetrics(text) {
|
|
|
42
48
|
transitions: top(lowerWords.filter((word) => TRANSITIONS.includes(word)), 8),
|
|
43
49
|
};
|
|
44
50
|
}
|
|
51
|
+
export function measureFounderFingerprint(text) {
|
|
52
|
+
const draftWords = words(text);
|
|
53
|
+
const wordDenominator = Math.max(1, draftWords.length);
|
|
54
|
+
const contractionCount = draftWords.filter((word) => CONTRACTIONS.has(word.toLowerCase().replaceAll('’', "'"))).length;
|
|
55
|
+
const draftSentences = sentences(text);
|
|
56
|
+
const sentenceDenominator = Math.max(1, draftSentences.length);
|
|
57
|
+
const buckets = { short: 0, medium: 0, long: 0 };
|
|
58
|
+
for (const sentence of draftSentences) {
|
|
59
|
+
const length = words(sentence.text).length;
|
|
60
|
+
if (length <= 8)
|
|
61
|
+
buckets.short += 1;
|
|
62
|
+
else if (length <= 20)
|
|
63
|
+
buckets.medium += 1;
|
|
64
|
+
else
|
|
65
|
+
buckets.long += 1;
|
|
66
|
+
}
|
|
67
|
+
const nonblankLines = text.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
68
|
+
const bulletCount = nonblankLines.filter((line) => /^\s*(?:[-*+] |\d+[.)] )/.test(line)).length;
|
|
69
|
+
return {
|
|
70
|
+
contractionRate: contractionCount / wordDenominator,
|
|
71
|
+
sentenceLengthDistribution: {
|
|
72
|
+
short: buckets.short / sentenceDenominator,
|
|
73
|
+
medium: buckets.medium / sentenceDenominator,
|
|
74
|
+
long: buckets.long / sentenceDenominator,
|
|
75
|
+
},
|
|
76
|
+
bulletRate: bulletCount / Math.max(1, nonblankLines.length),
|
|
77
|
+
// En dashes per word. The denominator is clamped to one so punctuation-only input remains defined.
|
|
78
|
+
enDashRate: (text.match(/–/g) ?? []).length / wordDenominator,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
45
81
|
export function buildProfile(samples, avoid = []) {
|
|
46
82
|
if (samples.length < 2)
|
|
47
83
|
throw new Error('Provide at least two local writing samples.');
|
|
@@ -70,8 +106,24 @@ export function analyzeVoiceDna(text, profile) {
|
|
|
70
106
|
findings.push(finding('dna.case-style', 'yellow', 1, text.slice(0, 160), `Draft uses ${metrics.caseStyle} casing; profile uses ${profile.metrics.caseStyle}.`, 'Use the profile’s normal casing.'));
|
|
71
107
|
if (metrics.pointOfView !== profile.metrics.pointOfView && profile.metrics.pointOfView !== 'mixed')
|
|
72
108
|
findings.push(finding('dna.point-of-view', 'yellow', 1, text.slice(0, 160), `Draft point of view is ${metrics.pointOfView}; profile is ${profile.metrics.pointOfView}.`, 'Restore the writer’s normal narrative distance.'));
|
|
109
|
+
if (profile.version === '3') {
|
|
110
|
+
const measured = measureFounderFingerprint(text);
|
|
111
|
+
const fingerprintChecks = [
|
|
112
|
+
['contraction-rate', measured.contractionRate, profile.fingerprint.contractionRate, profile.tolerances.contractionRate],
|
|
113
|
+
['sentence-length-distribution', Math.max(Math.abs(measured.sentenceLengthDistribution.short - profile.fingerprint.sentenceLengthDistribution.short), Math.abs(measured.sentenceLengthDistribution.medium - profile.fingerprint.sentenceLengthDistribution.medium), Math.abs(measured.sentenceLengthDistribution.long - profile.fingerprint.sentenceLengthDistribution.long)), 0, profile.tolerances.sentenceLengthDistribution],
|
|
114
|
+
['bullet-rate', measured.bulletRate, profile.fingerprint.bulletRate, profile.tolerances.bulletRate],
|
|
115
|
+
['en-dash-rate', measured.enDashRate, profile.fingerprint.enDashRate, profile.tolerances.enDashRate],
|
|
116
|
+
];
|
|
117
|
+
for (const [id, actual, target, metricTolerance] of fingerprintChecks) {
|
|
118
|
+
const drift = id === 'sentence-length-distribution' ? actual : Math.abs(actual - target);
|
|
119
|
+
if (drift > metricTolerance.absolute) {
|
|
120
|
+
const calibration = metricTolerance.calibrated ? 'calibrated' : 'uncalibrated';
|
|
121
|
+
findings.push(finding(`dna.fingerprint.${id}`, 'yellow', 1, text.slice(0, 160), `Voice fingerprint drift (${Number(drift.toFixed(3))}) exceeds the ${calibration} tolerance (${metricTolerance.absolute}).`, 'Review this metric as a non-blocking voice cue.'));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
73
125
|
const red = findings.filter((item) => item.severity === 'red').length;
|
|
74
126
|
const yellow = findings.length - red;
|
|
75
127
|
const score = Math.max(0, 100 - red * 25 - yellow * 7);
|
|
76
|
-
return { engine: 'voice_dna', version:
|
|
128
|
+
return { engine: 'voice_dna', version: profile.version, score, passed: red === 0, findings };
|
|
77
129
|
}
|