/** * tests/DSLRuntimeTyping.test.js — duration seconds, required fields, and * type validation of insertions / updates / provider retrievals. * * - Duration literals now accept s/m/h/d/w: `BEHAVES { ttl 30s }` is 30s. * - Definition fields are REQUIRED by default (`field: type`); `field: type?` * marks a field optional. addNode enforces presence on insert. * - Provider-returned edges are validated against the fact's declared typing: * a value-carrying fact must return { value, possibility } with a value of * the declared type, and possibilities must lie in [0, 1]. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { Arbiter } from '@arbiter/core'; import { DSLRuntime } from '../src/runtime/DSLRuntime.js'; describe('DSLRuntime typing', () => { it('accepts seconds/minutes/hours/days/weeks in duration literals', async () => { const dsl = ` definition Employee { id: string? } definition Doc { id: string? } fact *a(user: Employee, amount: number) BEHAVES { ttl 30s } fact *b(user: Employee, amount: number) BEHAVES { ttl 2m } fact *c(user: Employee, amount: number) BEHAVES { ttl 1h } fact *d(user: Employee, amount: number) BEHAVES { ttl 3d } fact *e(user: Employee, amount: number) BEHAVES { ttl 1w } `; const rt = new DSLRuntime(new Arbiter()).compile(dsl, 'rt-units'); assert.equal(rt.relations.get('a').ttlMs, 30_000); assert.equal(rt.relations.get('b').ttlMs, 120_000); assert.equal(rt.relations.get('c').ttlMs, 3_600_000); assert.equal(rt.relations.get('d').ttlMs, 259_200_000); assert.equal(rt.relations.get('e').ttlMs, 604_800_000); }); it('enforces required definition fields on node insert', () => { const rt = new DSLRuntime(new Arbiter()).compile(` definition Employee { id: string level: number active: boolean? } `, 'rt-req'); // id and level are required (no `?`); active is optional. assert.throws(() => rt.addNode('u:1', 'Employee', { level: 3 }), /missing required field 'Employee.id'/); assert.throws(() => rt.addNode('u:2', 'Employee', { id: 'u:2' }), /missing required field 'Employee.level'/); rt.addNode('u:3', 'Employee', { id: 'u:3', level: 5 }); // both required, no active -> ok rt.addNode('u:4', 'Employee', { id: 'u:4', level: 5, active: true }); }); it('exposes requiredness in the schema snapshot', () => { const rt = new DSLRuntime(new Arbiter()).compile(` definition Employee { id: string level: number? } `, 'rt-schema-req'); const employee = rt.getSchema().types.find(t => t.name === 'Employee'); assert.equal(employee.fields.find(f => f.name === 'id').required, true); assert.equal(employee.fields.find(f => f.name === 'level').required, false); }); it('validates a provider-returned value against the declared value type', async () => { const rt = new DSLRuntime(new Arbiter()).compile(` definition Employee { id: string? } definition Doc { id: string? } fact *balance(user: Employee, amount: number) evidence can_spend(user: Employee, doc: Doc) { balance(user, 1) } `, 'rt-valuetype'); rt.addNode('u:1', 'Employee', {}); rt.addNode('doc:9', 'Doc', {}); rt.registerFact('balance', async () => ({ possibility: 1.0, value: 'high' })); await assert.rejects(() => rt.check('u:1', 'can_spend', 'doc:9'), /must be number/); }); it('requires a value for a value-carrying fact (no bare-number shorthand)', async () => { const rt = new DSLRuntime(new Arbiter()).compile(` definition Employee { id: string? } definition Doc { id: string? } fact *balance(user: Employee, amount: number) evidence can_spend(user: Employee, doc: Doc) { balance(user, 1) } `, 'rt-valshape'); rt.addNode('u:1', 'Employee', {}); rt.addNode('doc:9', 'Doc', {}); rt.registerFact('balance', async () => 0.9); await assert.rejects(() => rt.check('u:1', 'can_spend', 'doc:9'), /value-carrying fact/); rt.registerFact('balance', async () => ({ possibility: 1.0 })); // missing value await assert.rejects(() => rt.check('u:1', 'can_spend', 'doc:9'), /must supply a 'value'/); }); it('rejects a provider-returned possibility outside [0, 1]', async () => { const rt = new DSLRuntime(new Arbiter()).compile(` definition Employee { id: string? } definition Doc { id: string? } fact *owns(user: Employee, doc: Doc) evidence can_read(user: Employee, doc: Doc) { owns(user, doc) } `, 'rt-poss'); rt.addNode('u:1', 'Employee', {}); rt.addNode('doc:9', 'Doc', {}); rt.registerFact('owns', async () => ({ possibility: 2.0 })); await assert.rejects(() => rt.check('u:1', 'can_read', 'doc:9'), /invalid possibility/); }); it('enforces a literal value in evidence as an exact edge-value gate', async () => { const rt = new DSLRuntime(new Arbiter()).compile(` definition Employee { id: string? } fact balance(user: Employee, amount: number) evidence can_afford(user: Employee) { balance(user, 5) } `, 'rt-expected-value'); rt.addNode('u:1', 'Employee', {}); // An edge carrying amount 3 must NOT satisfy balance(user, 5) — without the // gate every balance edge matched regardless of amount (silent over-grant). rt.addRelation('u:1', 'balance', 'u:1', { possibility: 1.0, value: 3 }); const denied = await rt.check('u:1', 'can_afford', 'u:1'); assert.equal(denied.possibility, 0, 'value-3 edge must not satisfy balance(user, 5)'); rt.addRelation('u:1', 'balance', 'u:1', { possibility: 0.8, value: 5 }); const granted = await rt.check('u:1', 'can_afford', 'u:1'); assert.equal(granted.possibility, 0.8, 'value-5 edge must satisfy balance(user, 5)'); }); it('treats a value-typed evidence OBJECT param as the expected edge value', async () => { const rt = new DSLRuntime(new Arbiter()).compile(` definition Employee { id: string? } fact balance(user: Employee, amount: number) evidence can_withdraw(user: Employee, amount: number) { balance(user, amount) } `, 'rt-value-object'); rt.addNode('u:1', 'Employee', {}); rt.addRelation('u:1', 'balance', 'u:1', { possibility: 0.9, value: 5 }); // The check object is the VALUE, not a node key: grant only on exact match. const granted = await rt.check('u:1', 'can_withdraw', 5); assert.equal(granted.possibility, 0.9, 'value-5 check must match the value-5 edge'); const denied = await rt.check('u:1', 'can_withdraw', 3); assert.equal(denied.possibility, 0, 'value-3 check must not match the value-5 edge'); // Re-checking the granted value must not hit a value-3 cache entry. const again = await rt.check('u:1', 'can_withdraw', 5); assert.equal(again.possibility, 0.9, 'value-5 re-check must not be served the value-3 result'); // Direct fact check with a value object works the same way. const direct = await rt.check('u:1', 'balance', 5); assert.equal(direct.possibility, 0.9, 'direct balance(user, 5) must match the value-5 edge'); // A non-scalar object for a value-typed param is rejected loudly. await assert.rejects(() => rt.check('u:1', 'can_withdraw', 'u:1'), /must be number/); }); });