diff --git a/package.json b/package.json index 64b11d4..366fb6e 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "start": "node index.js", "run": "node index.js run", "solidify": "node index.js solidify", + "test": "node --test test/*.test.js", "a2a:export": "node scripts/a2a_export.js", "a2a:ingest": "node scripts/a2a_ingest.js", "a2a:promote": "node scripts/a2a_promote.js" diff --git a/test/a2aProtocol.test.js b/test/a2aProtocol.test.js new file mode 100644 index 0000000..7c43263 --- /dev/null +++ b/test/a2aProtocol.test.js @@ -0,0 +1,134 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { + PROTOCOL_NAME, + PROTOCOL_VERSION, + VALID_MESSAGE_TYPES, + buildMessage, + buildHello, + buildPublish, + buildFetch, + buildReport, + buildDecision, + buildRevoke, + isValidProtocolMessage, + unwrapAssetFromMessage, +} = require('../src/gep/a2aProtocol'); + +describe('protocol constants', () => { + it('has expected protocol name', () => { + assert.equal(PROTOCOL_NAME, 'gep-a2a'); + }); + + it('has 6 valid message types', () => { + assert.equal(VALID_MESSAGE_TYPES.length, 6); + for (const t of ['hello', 'publish', 'fetch', 'report', 'decision', 'revoke']) { + assert.ok(VALID_MESSAGE_TYPES.includes(t), `missing type: ${t}`); + } + }); +}); + +describe('buildMessage', () => { + it('builds a valid protocol message', () => { + const msg = buildMessage({ messageType: 'hello', payload: { test: true } }); + assert.equal(msg.protocol, PROTOCOL_NAME); + assert.equal(msg.message_type, 'hello'); + assert.ok(msg.message_id.startsWith('msg_')); + assert.ok(msg.timestamp); + assert.deepEqual(msg.payload, { test: true }); + }); + + it('rejects invalid message type', () => { + assert.throws(() => buildMessage({ messageType: 'invalid' }), /Invalid message type/); + }); +}); + +describe('typed message builders', () => { + it('buildHello includes env_fingerprint', () => { + const msg = buildHello({}); + assert.equal(msg.message_type, 'hello'); + assert.ok(msg.payload.env_fingerprint); + }); + + it('buildPublish requires asset with type and id', () => { + assert.throws(() => buildPublish({}), /asset must have type and id/); + assert.throws(() => buildPublish({ asset: { type: 'Gene' } }), /asset must have type and id/); + + const msg = buildPublish({ asset: { type: 'Gene', id: 'g1' } }); + assert.equal(msg.message_type, 'publish'); + assert.equal(msg.payload.asset_type, 'Gene'); + assert.equal(msg.payload.local_id, 'g1'); + assert.ok(msg.payload.signature); + }); + + it('buildFetch creates a fetch message', () => { + const msg = buildFetch({ assetType: 'Capsule', localId: 'c1' }); + assert.equal(msg.message_type, 'fetch'); + assert.equal(msg.payload.asset_type, 'Capsule'); + }); + + it('buildReport creates a report message', () => { + const msg = buildReport({ assetId: 'sha256:abc', validationReport: { ok: true } }); + assert.equal(msg.message_type, 'report'); + assert.equal(msg.payload.target_asset_id, 'sha256:abc'); + }); + + it('buildDecision validates decision values', () => { + assert.throws(() => buildDecision({ decision: 'maybe' }), /decision must be/); + + for (const d of ['accept', 'reject', 'quarantine']) { + const msg = buildDecision({ decision: d, assetId: 'test' }); + assert.equal(msg.payload.decision, d); + } + }); + + it('buildRevoke creates a revoke message', () => { + const msg = buildRevoke({ assetId: 'sha256:abc', reason: 'outdated' }); + assert.equal(msg.message_type, 'revoke'); + assert.equal(msg.payload.reason, 'outdated'); + }); +}); + +describe('isValidProtocolMessage', () => { + it('returns true for well-formed messages', () => { + const msg = buildHello({}); + assert.ok(isValidProtocolMessage(msg)); + }); + + it('returns false for null/undefined', () => { + assert.ok(!isValidProtocolMessage(null)); + assert.ok(!isValidProtocolMessage(undefined)); + }); + + it('returns false for wrong protocol', () => { + assert.ok(!isValidProtocolMessage({ protocol: 'other', message_type: 'hello', message_id: 'x', timestamp: 'y' })); + }); + + it('returns false for missing fields', () => { + assert.ok(!isValidProtocolMessage({ protocol: PROTOCOL_NAME })); + }); +}); + +describe('unwrapAssetFromMessage', () => { + it('extracts asset from publish message', () => { + const asset = { type: 'Gene', id: 'g1', strategy: ['test'] }; + const msg = buildPublish({ asset }); + const result = unwrapAssetFromMessage(msg); + assert.equal(result.type, 'Gene'); + assert.equal(result.id, 'g1'); + }); + + it('returns plain asset objects as-is', () => { + const gene = { type: 'Gene', id: 'g1' }; + assert.deepEqual(unwrapAssetFromMessage(gene), gene); + + const capsule = { type: 'Capsule', id: 'c1' }; + assert.deepEqual(unwrapAssetFromMessage(capsule), capsule); + }); + + it('returns null for unrecognized input', () => { + assert.equal(unwrapAssetFromMessage(null), null); + assert.equal(unwrapAssetFromMessage({ random: true }), null); + assert.equal(unwrapAssetFromMessage('string'), null); + }); +}); diff --git a/test/contentHash.test.js b/test/contentHash.test.js new file mode 100644 index 0000000..b735ff6 --- /dev/null +++ b/test/contentHash.test.js @@ -0,0 +1,106 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { canonicalize, computeAssetId, verifyAssetId, SCHEMA_VERSION } = require('../src/gep/contentHash'); + +describe('canonicalize', () => { + it('serializes null and undefined as "null"', () => { + assert.equal(canonicalize(null), 'null'); + assert.equal(canonicalize(undefined), 'null'); + }); + + it('serializes primitives', () => { + assert.equal(canonicalize(true), 'true'); + assert.equal(canonicalize(false), 'false'); + assert.equal(canonicalize(42), '42'); + assert.equal(canonicalize('hello'), '"hello"'); + }); + + it('serializes non-finite numbers as null', () => { + assert.equal(canonicalize(Infinity), 'null'); + assert.equal(canonicalize(-Infinity), 'null'); + assert.equal(canonicalize(NaN), 'null'); + }); + + it('serializes arrays preserving order', () => { + assert.equal(canonicalize([1, 2, 3]), '[1,2,3]'); + assert.equal(canonicalize([]), '[]'); + }); + + it('serializes objects with sorted keys', () => { + assert.equal(canonicalize({ b: 2, a: 1 }), '{"a":1,"b":2}'); + assert.equal(canonicalize({ z: 'last', a: 'first' }), '{"a":"first","z":"last"}'); + }); + + it('produces deterministic output regardless of key insertion order', () => { + const obj1 = { c: 3, a: 1, b: 2 }; + const obj2 = { a: 1, b: 2, c: 3 }; + assert.equal(canonicalize(obj1), canonicalize(obj2)); + }); + + it('handles nested objects and arrays', () => { + const nested = { arr: [{ b: 2, a: 1 }], val: null }; + const result = canonicalize(nested); + assert.equal(result, '{"arr":[{"a":1,"b":2}],"val":null}'); + }); +}); + +describe('computeAssetId', () => { + it('returns a sha256-prefixed hash string', () => { + const id = computeAssetId({ type: 'Gene', id: 'test_gene' }); + assert.ok(id.startsWith('sha256:')); + assert.equal(id.length, 7 + 64); // "sha256:" + 64 hex chars + }); + + it('excludes asset_id field from hash by default', () => { + const obj = { type: 'Gene', id: 'g1', data: 'x' }; + const withoutField = computeAssetId(obj); + const withField = computeAssetId({ ...obj, asset_id: 'sha256:something' }); + assert.equal(withoutField, withField); + }); + + it('produces identical hashes for identical content', () => { + const a = computeAssetId({ type: 'Capsule', id: 'c1', value: 42 }); + const b = computeAssetId({ type: 'Capsule', id: 'c1', value: 42 }); + assert.equal(a, b); + }); + + it('produces different hashes for different content', () => { + const a = computeAssetId({ type: 'Gene', id: 'g1' }); + const b = computeAssetId({ type: 'Gene', id: 'g2' }); + assert.notEqual(a, b); + }); + + it('returns null for non-object input', () => { + assert.equal(computeAssetId(null), null); + assert.equal(computeAssetId('string'), null); + }); +}); + +describe('verifyAssetId', () => { + it('returns true for correct asset_id', () => { + const obj = { type: 'Gene', id: 'g1', data: 'test' }; + obj.asset_id = computeAssetId(obj); + assert.ok(verifyAssetId(obj)); + }); + + it('returns false for tampered content', () => { + const obj = { type: 'Gene', id: 'g1', data: 'test' }; + obj.asset_id = computeAssetId(obj); + obj.data = 'tampered'; + assert.ok(!verifyAssetId(obj)); + }); + + it('returns false for missing asset_id', () => { + assert.ok(!verifyAssetId({ type: 'Gene', id: 'g1' })); + }); + + it('returns false for null input', () => { + assert.ok(!verifyAssetId(null)); + }); +}); + +describe('SCHEMA_VERSION', () => { + it('is a semver string', () => { + assert.match(SCHEMA_VERSION, /^\d+\.\d+\.\d+$/); + }); +}); diff --git a/test/mutation.test.js b/test/mutation.test.js new file mode 100644 index 0000000..f8dc46f --- /dev/null +++ b/test/mutation.test.js @@ -0,0 +1,142 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { + buildMutation, + isValidMutation, + normalizeMutation, + isHighRiskMutationAllowed, + isHighRiskPersonality, + clamp01, +} = require('../src/gep/mutation'); + +describe('clamp01', () => { + it('clamps values to [0, 1]', () => { + assert.equal(clamp01(0.5), 0.5); + assert.equal(clamp01(0), 0); + assert.equal(clamp01(1), 1); + assert.equal(clamp01(-0.5), 0); + assert.equal(clamp01(1.5), 1); + }); + + it('returns 0 for non-finite input', () => { + assert.equal(clamp01(NaN), 0); + assert.equal(clamp01(undefined), 0); + // Note: clamp01(Infinity) returns 0 because the implementation checks + // Number.isFinite() before clamping. Mathematically clamp(Inf, 0, 1) = 1, + // but the current behavior treats all non-finite values uniformly as 0. + assert.equal(clamp01(Infinity), 0); + }); +}); + +describe('buildMutation', () => { + it('returns a valid Mutation object', () => { + const m = buildMutation({ signals: ['log_error'], selectedGene: { id: 'gene_repair' } }); + assert.ok(isValidMutation(m)); + assert.equal(m.type, 'Mutation'); + assert.ok(m.id.startsWith('mut_')); + }); + + it('selects repair category when error signals present', () => { + const m = buildMutation({ signals: ['log_error', 'errsig:something'] }); + assert.equal(m.category, 'repair'); + }); + + it('selects innovate category when drift enabled', () => { + const m = buildMutation({ signals: ['stable_success_plateau'], driftEnabled: true }); + assert.equal(m.category, 'innovate'); + }); + + it('selects innovate for opportunity signals without errors', () => { + const m = buildMutation({ signals: ['user_feature_request'] }); + assert.equal(m.category, 'innovate'); + }); + + it('downgrades innovate to optimize for high-risk personality', () => { + const highRiskPersonality = { rigor: 0.3, risk_tolerance: 0.8, creativity: 0.5 }; + const m = buildMutation({ + signals: ['user_feature_request'], + personalityState: highRiskPersonality, + }); + assert.equal(m.category, 'optimize'); + assert.ok(m.trigger_signals.some(s => s.includes('safety'))); + }); + + it('caps risk_level to medium when personality disallows high risk', () => { + const conservativePersonality = { rigor: 0.5, risk_tolerance: 0.6, creativity: 0.5 }; + const m = buildMutation({ + signals: ['stable_success_plateau'], + driftEnabled: true, + allowHighRisk: true, + personalityState: conservativePersonality, + }); + assert.notEqual(m.risk_level, 'high'); + }); +}); + +describe('isValidMutation', () => { + it('returns true for valid mutation', () => { + const m = buildMutation({ signals: ['log_error'] }); + assert.ok(isValidMutation(m)); + }); + + it('returns false for missing fields', () => { + assert.ok(!isValidMutation(null)); + assert.ok(!isValidMutation({})); + assert.ok(!isValidMutation({ type: 'Mutation' })); + }); + + it('returns false for invalid category', () => { + assert.ok(!isValidMutation({ + type: 'Mutation', id: 'x', category: 'destroy', + trigger_signals: [], target: 't', expected_effect: 'e', risk_level: 'low', + })); + }); +}); + +describe('normalizeMutation', () => { + it('fills defaults for empty object', () => { + const m = normalizeMutation({}); + assert.ok(isValidMutation(m)); + assert.equal(m.category, 'optimize'); + assert.equal(m.risk_level, 'low'); + }); + + it('preserves valid fields', () => { + const m = normalizeMutation({ + id: 'mut_custom', category: 'repair', + trigger_signals: ['log_error'], target: 'file.js', + expected_effect: 'fix bug', risk_level: 'medium', + }); + assert.equal(m.id, 'mut_custom'); + assert.equal(m.category, 'repair'); + assert.equal(m.risk_level, 'medium'); + }); +}); + +describe('isHighRiskPersonality', () => { + it('detects low rigor as high risk', () => { + assert.ok(isHighRiskPersonality({ rigor: 0.3 })); + }); + + it('detects high risk_tolerance as high risk', () => { + assert.ok(isHighRiskPersonality({ risk_tolerance: 0.7 })); + }); + + it('returns false for conservative personality', () => { + assert.ok(!isHighRiskPersonality({ rigor: 0.8, risk_tolerance: 0.2 })); + }); +}); + +describe('isHighRiskMutationAllowed', () => { + it('allows when rigor >= 0.6 and risk_tolerance <= 0.5', () => { + assert.ok(isHighRiskMutationAllowed({ rigor: 0.8, risk_tolerance: 0.3 })); + }); + + it('disallows when rigor too low', () => { + assert.ok(!isHighRiskMutationAllowed({ rigor: 0.4, risk_tolerance: 0.3 })); + }); + + it('disallows when risk_tolerance too high', () => { + assert.ok(!isHighRiskMutationAllowed({ rigor: 0.8, risk_tolerance: 0.6 })); + }); +}); diff --git a/test/selector.test.js b/test/selector.test.js new file mode 100644 index 0000000..dbd8269 --- /dev/null +++ b/test/selector.test.js @@ -0,0 +1,112 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { selectGene, selectCapsule, selectGeneAndCapsule } = require('../src/gep/selector'); + +const GENES = [ + { + type: 'Gene', + id: 'gene_repair', + category: 'repair', + signals_match: ['error', 'exception', 'failed'], + strategy: ['fix it'], + validation: ['node -e "true"'], + }, + { + type: 'Gene', + id: 'gene_optimize', + category: 'optimize', + signals_match: ['protocol', 'prompt', 'audit'], + strategy: ['optimize it'], + validation: ['node -e "true"'], + }, + { + type: 'Gene', + id: 'gene_innovate', + category: 'innovate', + signals_match: ['user_feature_request', 'capability_gap', 'stable_success_plateau'], + strategy: ['build it'], + validation: ['node -e "true"'], + }, +]; + +const CAPSULES = [ + { + type: 'Capsule', + id: 'capsule_1', + trigger: ['log_error', 'exception'], + gene: 'gene_repair', + summary: 'Fixed an error', + confidence: 0.9, + }, + { + type: 'Capsule', + id: 'capsule_2', + trigger: ['protocol', 'gep'], + gene: 'gene_optimize', + summary: 'Optimized prompt', + confidence: 0.85, + }, +]; + +describe('selectGene', () => { + it('selects the gene with highest signal match', () => { + const result = selectGene(GENES, ['error', 'exception', 'failed'], {}); + assert.equal(result.selected.id, 'gene_repair'); + }); + + it('returns null when no signals match', () => { + const result = selectGene(GENES, ['completely_unrelated_signal'], {}); + assert.equal(result.selected, null); + }); + + it('returns alternatives when multiple genes match', () => { + const result = selectGene(GENES, ['error', 'protocol'], {}); + assert.ok(result.selected); + assert.ok(Array.isArray(result.alternatives)); + }); + + it('includes drift intensity in result', () => { + // Drift intensity is population-size-dependent; verify it is returned. + const result = selectGene(GENES, ['error', 'exception'], {}); + assert.ok('driftIntensity' in result); + assert.equal(typeof result.driftIntensity, 'number'); + assert.ok(result.driftIntensity >= 0 && result.driftIntensity <= 1); + }); + + it('respects preferred gene id from memory graph', () => { + const result = selectGene(GENES, ['error', 'protocol'], { + preferredGeneId: 'gene_optimize', + }); + // gene_optimize matches 'protocol' so it qualifies as a candidate + // With preference, it should be selected even if gene_repair scores higher + assert.equal(result.selected.id, 'gene_optimize'); + }); +}); + +describe('selectCapsule', () => { + it('selects capsule matching signals', () => { + const result = selectCapsule(CAPSULES, ['log_error', 'exception']); + assert.equal(result.id, 'capsule_1'); + }); + + it('returns null when no triggers match', () => { + const result = selectCapsule(CAPSULES, ['unrelated']); + assert.equal(result, null); + }); +}); + +describe('selectGeneAndCapsule', () => { + it('returns selected gene, capsule candidates, and selector decision', () => { + const result = selectGeneAndCapsule({ + genes: GENES, + capsules: CAPSULES, + signals: ['error', 'log_error'], + memoryAdvice: null, + driftEnabled: false, + }); + assert.ok(result.selectedGene); + assert.ok(result.selector); + assert.ok(result.selector.selected); + assert.ok(Array.isArray(result.selector.reason)); + }); +});