7898a7990a
- DSLValueGraph integrates the evidence DSL with @arbiter/value-graph: measures become typed value-graph nodes; attach() wires runtime.measure through the graph. - DSLRuntime.check() now retrieves required MEASURE values and injects them as value-carrying self-edges, so an evidence comparator over a measure (e.g. budget_used(user) <= budget_limit(user)) evaluates — measure and evidence compose (provider-sourced AND value-graph-sourced), with 3 new composition tests. - BUILTIN_TYPES: bigint → buffer (the value-graph wire is JSON-free/bigint-free). - @arbiter/value-graph: file:../value-graph → ^0.1.0 (registry); CI auth adds @push-stream-std registry for the transitive dep. - rigor core ^3.1.2 / probe ^0.0.8.
234 lines
10 KiB
JavaScript
234 lines
10 KiB
JavaScript
/**
|
|
* tests/DSLRuntimeExt.test.js — extended DSLRuntime capabilities:
|
|
* - schema introspection (getSchema)
|
|
* - per-relation provider registration (registerFact/unregisterFact)
|
|
* - provider merging (registered + per-check overrides)
|
|
* - bounded fixed-point provider retrieval loop (edges satisfy other facts)
|
|
* - require() throw-on-deny
|
|
* - removal passthroughs and fact-relation check validation
|
|
* - timestamp/duration field typing
|
|
*/
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { Arbiter } from '@arbiter/core';
|
|
import { DSLRuntime } from '../src/runtime/DSLRuntime.js';
|
|
|
|
const BASE_DSL = `
|
|
definition Employee { id: string? level: number? active: boolean? }
|
|
definition Doc { id: string? created: timestamp? }
|
|
fact *owns(user: Employee, doc: Doc)
|
|
fact *banned(user: Employee)
|
|
evidence can_read(user: Employee, doc: Doc) { owns(user, doc) }
|
|
evidence can_open(user: Employee, doc: Doc) { WHEN can_read(user, doc) UNLESS banned(user) }
|
|
`;
|
|
|
|
function makeRuntime() {
|
|
return new DSLRuntime(new Arbiter()).compile(BASE_DSL, 'rt-ext');
|
|
}
|
|
|
|
describe('DSLRuntime extended', () => {
|
|
it('exposes a serializable schema snapshot', () => {
|
|
const rt = makeRuntime();
|
|
const schema = rt.getSchema();
|
|
assert.ok(Array.isArray(schema.types));
|
|
const employee = schema.types.find(t => t.name === 'Employee');
|
|
assert.ok(employee);
|
|
assert.ok(employee.fields.some(f => f.name === 'level' && f.type === 'number'));
|
|
const owns = schema.facts.find(f => f.name === 'owns');
|
|
assert.equal(owns.injectable, true);
|
|
assert.equal(owns.params[1].type, 'Doc');
|
|
const can_open = schema.evidence.find(e => e.name === 'can_open');
|
|
assert.ok(can_open.dependsOn.includes('owns'));
|
|
assert.deepEqual(schema.providers, []);
|
|
assert.ok(rt.relationNames().includes('owns') && rt.relationNames().includes('can_read'));
|
|
});
|
|
|
|
it('registers, lists, and unregisters per-relation providers', () => {
|
|
const rt = makeRuntime();
|
|
rt.registerFact('owns', async () => 0.8);
|
|
assert.deepEqual(rt.registeredFacts(), ['owns']);
|
|
rt.registerFact('banned', async () => 0);
|
|
assert.deepEqual(rt.registeredFacts().sort(), ['banned', 'owns']);
|
|
rt.unregisterFact('banned');
|
|
assert.deepEqual(rt.registeredFacts(), ['owns']);
|
|
assert.throws(() => rt.registerFact('owns', 'not a function'), /must be a function/);
|
|
});
|
|
|
|
it('merges registered providers with per-check overrides', async () => {
|
|
const rt = makeRuntime();
|
|
rt.registerFact('owns', async () => 0.5);
|
|
rt.registerFact('banned', async () => 0);
|
|
rt.addNode('u:1', 'Employee', {});
|
|
rt.addNode('doc:9', 'Doc', {});
|
|
// registered owns (0.5) wins over nothing; per-check banned overrides
|
|
const res = await rt.check('u:1', 'can_open', 'doc:9', {
|
|
factProviders: { banned: async () => 0 }
|
|
});
|
|
assert.equal(res.possibility, 0.5);
|
|
assert.deepEqual(res.providedFacts.sort(), ['banned', 'owns']);
|
|
});
|
|
|
|
it('runs providers to a fixed point when edges satisfy other required facts', async () => {
|
|
// can_open needs owns (injectable). A registered owns provider returns an
|
|
// edge for a DIFFERENT injectable fact that can_open also requires via
|
|
// composition — here we add a transitive requirement to prove the loop.
|
|
const dsl = `
|
|
definition Employee { id: string? }
|
|
definition Doc { id: string? }
|
|
fact *owns(user: Employee, doc: Doc)
|
|
fact *granted(user: Employee, doc: Doc)
|
|
evidence base_read(user: Employee, doc: Doc) { owns(user, doc) }
|
|
evidence can_open(user: Employee, doc: Doc) { WHEN base_read(user, doc) UNLESS granted(user, doc) }
|
|
`;
|
|
const rt = new DSLRuntime(new Arbiter()).compile(dsl, 'rt-loop');
|
|
rt.addNode('u:1', 'Employee', {});
|
|
rt.addNode('doc:9', 'Doc', {});
|
|
let ownsCalls = 0;
|
|
let grantedCalls = 0;
|
|
rt.registerFact('owns', async () => {
|
|
ownsCalls++;
|
|
// First round the owns provider also supplies the granted edge (a
|
|
// fixed-point dependency: granted needs owns to have been retrieved).
|
|
return [
|
|
{ src: 'u:1', relation: 'owns', dst: 'doc:9', possibility: 0.9 },
|
|
{ src: 'u:1', relation: 'granted', dst: 'doc:9', possibility: 0 }
|
|
];
|
|
});
|
|
rt.registerFact('granted', async () => { grantedCalls++; return 0; });
|
|
const res = await rt.check('u:1', 'can_open', 'doc:9', { maxProviderRounds: 3 });
|
|
assert.equal(res.possibility, 0.9);
|
|
// granted was satisfied by the owns provider's extra edge, so its own
|
|
// provider was never needed in a later round.
|
|
assert.equal(grantedCalls, 0);
|
|
assert.ok(ownsCalls >= 1);
|
|
assert.deepEqual(res.providedFacts, ['owns']);
|
|
assert.deepEqual(res.missingFacts, []);
|
|
});
|
|
|
|
it('require() throws on denial and returns the result on grant', async () => {
|
|
const rt = makeRuntime();
|
|
rt.addNode('u:1', 'Employee', {});
|
|
rt.addNode('doc:9', 'Doc', {});
|
|
rt.registerFact('owns', async () => 0.9);
|
|
const ok = await rt.require('u:1', 'can_read', 'doc:9');
|
|
assert.equal(ok.possibility, 0.9);
|
|
rt.registerFact('owns', async () => 0);
|
|
await assert.rejects(
|
|
() => rt.require('u:1', 'can_read', 'doc:9'),
|
|
(err) => err.result && err.result.possibility === 0 && /denied/.test(err.message)
|
|
);
|
|
});
|
|
|
|
it('passes through node/relation removal', () => {
|
|
const rt = makeRuntime();
|
|
rt.addNode('u:1', 'Employee', {});
|
|
rt.addNode('doc:9', 'Doc', {});
|
|
rt.addRelation('u:1', 'owns', 'doc:9', { possibility: 1.0 });
|
|
rt.removeRelation('u:1', 'owns', 'doc:9');
|
|
assert.equal(rt.arbiter.check('u:1', 'owns', 'doc:9').possibility, 0);
|
|
rt.removeNode('u:1');
|
|
assert.equal(rt.arbiter.nodeIdByKey.has('u:1'), false);
|
|
});
|
|
|
|
it('validates fact-relation check endpoints like evidence', async () => {
|
|
const rt = makeRuntime();
|
|
rt.addNode('u:1', 'Employee', {});
|
|
rt.addNode('doc:9', 'Doc', {});
|
|
// can_read is evidence; owns is a fact — checking a fact still validates.
|
|
await assert.rejects(() => rt.check('u:1', 'owns', 'u:1', {}), /expected 'Doc'/);
|
|
});
|
|
|
|
it('accepts timestamp field values and rejects mistyped ones', () => {
|
|
const rt = makeRuntime();
|
|
rt.addNode('doc:9', 'Doc', { created: 1720000000000 });
|
|
rt.updateNodeData('doc:9', { created: '2026-08-03T00:00:00Z' });
|
|
assert.throws(() => rt.addNode('doc:8', 'Doc', { created: {} }), /must be timestamp/);
|
|
});
|
|
|
|
it('direct FACT checks consult the registered provider', async () => {
|
|
const rt = makeRuntime();
|
|
rt.addNode('u:1', 'Employee', {});
|
|
rt.addNode('doc:9', 'Doc', {});
|
|
let calls = 0;
|
|
rt.registerFact('owns', async () => { calls++; return 0.9; });
|
|
// Checking the fact directly (not via an evidence) must retrieve it.
|
|
const res = await rt.check('u:1', 'owns', 'doc:9');
|
|
assert.equal(res.possibility, 0.9);
|
|
assert.equal(calls, 1);
|
|
assert.deepEqual(res.requiredFacts, ['owns']);
|
|
assert.deepEqual(res.providedFacts, ['owns']);
|
|
// Without a provider and without an edge, it reports the missing fact.
|
|
const rt2 = new DSLRuntime(new Arbiter()).compile(BASE_DSL, 'rt-fact-miss');
|
|
rt2.addNode('u:1', 'Employee', {});
|
|
rt2.addNode('doc:9', 'Doc', {});
|
|
const missed = await rt2.check('u:1', 'owns', 'doc:9');
|
|
assert.equal(missed.possibility, 0);
|
|
assert.deepEqual(missed.missingFacts, [{ relation: 'owns', reason: 'no_provider' }]);
|
|
});
|
|
|
|
it('recompiling a scope uninstalls its stale relation configs', async () => {
|
|
const rt = new DSLRuntime(new Arbiter());
|
|
rt.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-stale');
|
|
rt.addNode('u:1', 'Employee', {});
|
|
rt.addNode('doc:9', 'Doc', {});
|
|
rt.addRelation('u:1', 'owns', 'doc:9', { possibility: 1.0 });
|
|
assert.equal(rt.arbiter.check('u:1', 'can_read', 'doc:9').possibility, 1.0);
|
|
|
|
// Recompile the SAME scope with a different program: can_read/owns are no
|
|
// longer declared and must not keep granting. A second scope's relations
|
|
// would be unaffected (compileMultiple coexistence).
|
|
rt.compile(`
|
|
definition Employee { id: string? }
|
|
definition Doc { id: string? }
|
|
fact shares(user: Employee, doc: Doc)
|
|
evidence can_share(user: Employee, doc: Doc) { shares(user, doc) }
|
|
`, 'rt-stale');
|
|
const stale = rt.arbiter.check('u:1', 'can_read', 'doc:9');
|
|
assert.equal(stale.possibility, 0, 'revoked can_read must not keep granting');
|
|
assert.ok(!rt.arbiter.relationConfigs.has('can_read'));
|
|
assert.ok(!rt.arbiter.relationConfigs.has('owns'));
|
|
assert.ok(rt.arbiter.relationConfigs.has('can_share'));
|
|
});
|
|
|
|
it('indexes measures and resolves them via a registered provider', async () => {
|
|
const rt = new DSLRuntime(new Arbiter()).compile(`
|
|
definition Employee { id: string? }
|
|
definition Project { id: string? }
|
|
measure budget_available(tenant: Employee, feature: string) { } PROVIDES number
|
|
measure clearance(user: Employee) { } PROVIDES string
|
|
`, 'rt-measures');
|
|
// Schema exposes measures with their return type.
|
|
const schema = rt.getSchema();
|
|
const budget = schema.measures.find(m => m.name === 'budget_available');
|
|
assert.ok(budget);
|
|
assert.equal(budget.returnType, 'number');
|
|
assert.deepEqual(budget.params.map(p => p.name), ['tenant', 'feature']);
|
|
assert.ok(rt.relationNames().includes('clearance'));
|
|
|
|
// No provider yet → resolution fails loudly.
|
|
await assert.rejects(() => rt.measure('budget_available', { tenant: 'tenant:acme', feature: 'tokens_in:gpt-4' }), /no provider registered/);
|
|
|
|
// Register a provider (the ARRA adapter would bridge the value-graph).
|
|
rt.registerMeasure('budget_available', async ({ tenant, feature }) => {
|
|
assert.equal(tenant, 'tenant:acme');
|
|
assert.equal(feature, 'tokens_in:gpt-4');
|
|
return { value: 1250, unit: 'tokens' };
|
|
});
|
|
const resolved = await rt.measure('budget_available', { tenant: 'tenant:acme', feature: 'tokens_in:gpt-4' });
|
|
assert.equal(resolved.value, 1250);
|
|
assert.equal(resolved.unit, 'tokens');
|
|
|
|
// Positional args bind by param order for unary measures.
|
|
rt.registerMeasure('clearance', async () => ({ value: 'secret' }));
|
|
assert.equal((await rt.measure('clearance', ['user:alice'])).value, 'secret');
|
|
|
|
// registerMeasure rejects undeclared names.
|
|
assert.throws(() => rt.registerMeasure('nope', async () => 1), /not a declared measure/);
|
|
});
|
|
});
|