1.13.0: measure + evidence compose; value-graph now a registry dep
- 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.
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* DSLValueGraph — evidence DSL declares the value-graph's typing/structures;
|
||||
* measures retrieve through the graph (partial-graph purposes), and attributes
|
||||
* that are neither cached nor computed are stored directly via setValue.
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Arbiter } from '@arbiter/core';
|
||||
import { ValueGraph } from '@arbiter/value-graph';
|
||||
import { DSLRuntime, DSLValueGraph } from '../src/index.js';
|
||||
|
||||
function makeRuntime(dsl) {
|
||||
return new DSLRuntime(new Arbiter()).compile(dsl, 'vg-test');
|
||||
}
|
||||
|
||||
const DSL = `
|
||||
definition Employee { id: string? }
|
||||
definition Project { id: string? }
|
||||
measure budget_available(tenant: Employee, feature: string) { } PROVIDES number
|
||||
measure clearance(user: Employee) { } PROVIDES string
|
||||
measure is_active(user: Employee) { } PROVIDES boolean
|
||||
`;
|
||||
|
||||
describe('DSLValueGraph integration', () => {
|
||||
it('derives the value-graph node schema from the DSL measures', () => {
|
||||
const dvg = new DSLValueGraph(makeRuntime(DSL));
|
||||
const schema = dvg.schema();
|
||||
assert.equal(schema.budget_available.returnType, 'number');
|
||||
assert.deepEqual(schema.budget_available.params.map((p) => p.name), ['tenant', 'feature']);
|
||||
assert.equal(schema.clearance.returnType, 'string');
|
||||
assert.equal(schema.is_active.returnType, 'boolean');
|
||||
});
|
||||
|
||||
it('stores attributes that are neither cached nor computed, and reads them back', () => {
|
||||
const dvg = new DSLValueGraph(makeRuntime(DSL));
|
||||
dvg.setValue('budget_available', { __subject: 'tenant:acme', tenant: 'tenant:acme', feature: 'tokens_in:gpt-4' }, 1250, { unit: 'tokens' });
|
||||
const entry = dvg.getValue('budget_available', { __subject: 'tenant:acme', tenant: 'tenant:acme', feature: 'tokens_in:gpt-4' });
|
||||
assert.equal(entry.value, 1250);
|
||||
assert.equal(entry.unit, 'tokens');
|
||||
assert.equal(entry.source, 'dsl');
|
||||
assert.equal(entry.fresh, true);
|
||||
});
|
||||
|
||||
it('measure() retrieves through the value-graph (partial-graph purposes)', async () => {
|
||||
const dvg = new DSLValueGraph(makeRuntime(DSL));
|
||||
dvg.setValue('clearance', { __subject: 'user:alice', user: 'user:alice' }, 'secret');
|
||||
const resolved = await dvg.measure('clearance', { __subject: 'user:alice', user: 'user:alice' });
|
||||
assert.equal(resolved.value, 'secret');
|
||||
});
|
||||
|
||||
it('attach() wires runtime.measure() through the value-graph', async () => {
|
||||
const rt = makeRuntime(DSL);
|
||||
const dvg = new DSLValueGraph(rt);
|
||||
dvg.setValue('is_active', { __subject: 'user:alice', user: 'user:alice' }, true);
|
||||
dvg.attach();
|
||||
const resolved = await rt.measure('is_active', { __subject: 'user:alice', user: 'user:alice' });
|
||||
assert.equal(resolved.value, true);
|
||||
// A measure with nothing stored resolves to null — no provider explosion.
|
||||
const missing = await rt.measure('clearance', { __subject: 'user:bob', user: 'user:bob' });
|
||||
assert.equal(missing.value, null);
|
||||
});
|
||||
|
||||
it('resolves a value via an external callback resolver (the compute substrate)', async () => {
|
||||
const dvg = new DSLValueGraph(makeRuntime(DSL));
|
||||
dvg.resolve('budget_available', (subject, params, ctx, cb) => {
|
||||
cb(null, { value: 999, unit: 'tokens', source: 'overlay:balances' });
|
||||
});
|
||||
const entry = await dvg.measure('budget_available', { __subject: 'tenant:acme', tenant: 'tenant:acme', feature: 'x' });
|
||||
assert.equal(entry.value, 999);
|
||||
assert.equal(entry.unit, 'tokens');
|
||||
assert.equal(entry.source, 'overlay:balances');
|
||||
});
|
||||
|
||||
it('validates stored values against the declared PROVIDES type', () => {
|
||||
const dvg = new DSLValueGraph(makeRuntime(DSL));
|
||||
assert.throws(() => dvg.setValue('budget_available', { feature: 'x' }, 'not-a-number'), /must match declared type 'number'/);
|
||||
assert.throws(() => dvg.setValue('clearance', { user: 'u' }, 42), /must match declared type 'string'/);
|
||||
});
|
||||
|
||||
it('validates parameter bindings against the declared parameter types', () => {
|
||||
const dvg = new DSLValueGraph(makeRuntime(DSL));
|
||||
assert.throws(() => dvg.setValue('budget_available', { tenant: 't', feature: 42 }, 5),
|
||||
/parameter 'feature' of 'budget_available' must match declared type 'string'/);
|
||||
});
|
||||
|
||||
it('rejects unknown measure names', async () => {
|
||||
const dvg = new DSLValueGraph(makeRuntime(DSL));
|
||||
assert.throws(() => dvg.setValue('nope', {}, 1), /not a declared measure/);
|
||||
assert.throws(() => dvg.getValue('nope', {}), /not a declared measure/);
|
||||
assert.throws(() => dvg.resolve('nope', () => 1), /not a declared measure/);
|
||||
await assert.rejects(() => dvg.measure('nope', {}), /not a declared measure/);
|
||||
});
|
||||
|
||||
it('binds positional args by declared parameter order', () => {
|
||||
const dvg = new DSLValueGraph(makeRuntime(DSL));
|
||||
dvg.setValue('budget_available', ['tenant:acme', 'tokens_in:gpt-4'], 50);
|
||||
const entry = dvg.getValue('budget_available', ['tenant:acme', 'tokens_in:gpt-4']);
|
||||
assert.equal(entry.value, 50);
|
||||
});
|
||||
|
||||
it('the value-graph itself enforces declared return types on resolver results', async () => {
|
||||
const dvg = new DSLValueGraph(makeRuntime(DSL));
|
||||
dvg.resolve('clearance', (s, p, ctx, cb) => cb(null, 12345)); // number, but PROVIDES string
|
||||
const err = await dvg.measure('clearance', { __subject: 'user:alice', user: 'user:alice' }).then(() => null, (e) => e);
|
||||
assert.ok(err, 'a wrong-typed resolver result must be rejected');
|
||||
assert.match(err.message, /must match declared type 'string'/);
|
||||
});
|
||||
|
||||
it('shares a caller-supplied value graph and supports subjectOf', () => {
|
||||
const vg = new ValueGraph();
|
||||
const dvg = new DSLValueGraph(makeRuntime(DSL), {
|
||||
valueGraph: vg,
|
||||
subjectOf: (name, args) => args.tenant || args.user || 'global'
|
||||
});
|
||||
assert.equal(dvg.vg, vg);
|
||||
dvg.setValue('clearance', { user: 'user:alice' }, 'top-secret');
|
||||
assert.equal(dvg.getValue('clearance', { user: 'user:alice' }).value, 'top-secret');
|
||||
});
|
||||
|
||||
it('rigor: setValue/getValue round-trips always return the stored value', async () => {
|
||||
const { rigor } = await import('@rigor/core');
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('roundtrip', (value) => {
|
||||
const dvg = new DSLValueGraph(makeRuntime(DSL));
|
||||
dvg.setValue('budget_available', { __subject: 'tenant:acme', tenant: 'tenant:acme', feature: 'f' }, value);
|
||||
const e = dvg.getValue('budget_available', { __subject: 'tenant:acme', tenant: 'tenant:acme', feature: 'f' });
|
||||
return e ? e.value : 'MISSING';
|
||||
}, rigor.args(rigor.gen.int(-100000, 100000)))],
|
||||
rigor.crucible([rigor.invariant('roundtrip', (ctx) => ctx.actual === ctx.args[0])])
|
||||
).run({ effort: 150, seed: 'dsl-vg-roundtrip' });
|
||||
if (report.status !== 'passed') {
|
||||
throw new Error(`DSLValueGraph rigor roundtrip failed: ${JSON.stringify((report.failures || []).slice(0, 3))}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('rigor: setValue rejects wrong-typed values for every generated value', async () => {
|
||||
const { rigor } = await import('@rigor/core');
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('typed-reject', (value) => {
|
||||
const dvg = new DSLValueGraph(makeRuntime(DSL));
|
||||
try {
|
||||
dvg.setValue('clearance', { user: 'u' }, value); // PROVIDES string
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}, rigor.args(rigor.gen.int(0, 100)))],
|
||||
rigor.crucible([rigor.invariant('rejects', (ctx) => ctx.actual.ok === false && /must match declared type 'string'/.test(ctx.actual.error))])
|
||||
).run({ effort: 150, seed: 'dsl-vg-typecheck' });
|
||||
if (report.status !== 'passed') {
|
||||
throw new Error(`DSLValueGraph rigor typecheck failed: ${JSON.stringify((report.failures || []).slice(0, 3))}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('the aligned type vocabulary round-trips buffer/interval/any/duration', () => {
|
||||
const dvg = new DSLValueGraph(makeRuntime(`
|
||||
definition T { id: string }
|
||||
measure quota(user: string) { } PROVIDES buffer
|
||||
measure span(user: string) { } PROVIDES interval
|
||||
measure whatever(user: string) { } PROVIDES any
|
||||
measure window(user: string) { } PROVIDES duration
|
||||
`));
|
||||
const bytes = new Uint8Array([1, 2, 3, 254, 255]);
|
||||
dvg.setValue('quota', { __subject: 'u', user: 'u' }, bytes);
|
||||
assert.deepEqual([...dvg.getValue('quota', { __subject: 'u', user: 'u' }).value], [...bytes]);
|
||||
dvg.setValue('span', { __subject: 'u', user: 'u' }, { lower: 100, upper: 200 });
|
||||
assert.deepEqual(dvg.getValue('span', { __subject: 'u', user: 'u' }).value, { lower: 100, upper: 200 });
|
||||
dvg.setValue('whatever', { __subject: 'u', user: 'u' }, 42); // any → accepted
|
||||
assert.equal(dvg.getValue('whatever', { __subject: 'u', user: 'u' }).value, 42);
|
||||
dvg.setValue('window', { __subject: 'u', user: 'u' }, '1h');
|
||||
assert.equal(dvg.getValue('window', { __subject: 'u', user: 'u' }).value, '1h');
|
||||
});
|
||||
|
||||
it('sync() re-registers measures after the runtime recompiles', () => {
|
||||
const rt = new DSLRuntime(new Arbiter()).compile('measure a() { } PROVIDES number', 'v1');
|
||||
const dvg = new DSLValueGraph(rt);
|
||||
rt.compile('measure b() { } PROVIDES string', 'v2'); // a removed, b added
|
||||
// Before sync: stale schema.
|
||||
assert.throws(() => dvg.setValue('b', {}, 'x'), /not a declared measure/);
|
||||
// After sync: b is usable, a is gone.
|
||||
dvg.sync();
|
||||
dvg.setValue('b', {}, 'x');
|
||||
assert.equal(dvg.getValue('b', {}).value, 'x');
|
||||
assert.throws(() => dvg.setValue('a', {}, 1), /not a declared measure/);
|
||||
// schema() reflects the current measure set
|
||||
assert.ok(dvg.schema().b);
|
||||
assert.ok(!dvg.schema().a);
|
||||
});
|
||||
|
||||
it('attach() re-syncs and unregisters providers for removed measures after recompile', async () => {
|
||||
const rt = new DSLRuntime(new Arbiter()).compile('measure a() { } PROVIDES number', 'v1');
|
||||
const dvg = new DSLValueGraph(rt);
|
||||
dvg.attach();
|
||||
rt.compile('measure b() { } PROVIDES string', 'v2');
|
||||
dvg.attach(); // re-sync + re-wire
|
||||
dvg.setValue('b', { __subject: 't' }, 'hello');
|
||||
assert.equal((await rt.measure('b', { __subject: 't' })).value, 'hello');
|
||||
// a is no longer declared after the recompile → the runtime rejects it
|
||||
await assert.rejects(() => rt.measure('a', { __subject: 't' }), /unknown measure/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user