Skip to content

feat(protocol): share one evaluation among standing queries that differ only in bound constants, and cache their plans - #254

Merged
jsam merged 20 commits into
mainfrom
fm/il-session-scale-2
Oct 4, 2026
Merged

jsam merged 20 commits into
mainfrom
fm/il-session-scale-2

Conversation

@jsam

@jsam jsam commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Intent

Keep fixing bugs and QCing the engine. Millisecond query and update latency must not regress; no new locks on hot paths. (Context: a hostile red-team of the voice-agent reference architecture measured the engine under that design, full method and data in ~/firstmate/data/voice-arch-novelty/report.md §6.2 and its appendices: with one session-bound standing subscription per session in one tenant graph, every commit re-evaluates every session's view, each evaluation costs about as much as the whole tenant (session bindings do not restrict work for non-recursive packs; Magic Sets only rewrites recursive relations), and ~6.9 ms of each re-evaluation is recompilation with no plan cache. Result: about 10 sessions fit a voice budget (write->delta p99 <= 100 ms); 50 sessions give p50 214 ms; 200 give 3.6 s. One shared unbound view with client-side routing cut CPU 16-27x and latency 2-24x.)

What Changed

  • Standing queries (.subscribe) that differ only in string or integer constants of positive atoms (for example one per session, ?speech("s-1", G, T) and ?speech("s-2", G, T)) now form a family (src/protocol/subscription/parameterized.rs). The family evaluates the lifted query once per commit and gives each view exactly the rows for its own constants, so deltas, revisions, errors and access checks stay per subscription. Sharing adjusts to latency. A family shares only when it has more views than query threads, and only after a background probe shows the shared evaluation is no slower than the views' own evaluations run in parallel. The probe uses its own permit, so no delta waits for it. A family stops sharing when shared rounds are slower on average and tries again later with backoff. Rule changes reset this decision, and a family never shares when a constant binds a relation that reads a recursive relation. The new [subscriptions] share_parameterized setting (default true, overridable with INPUTLAYER_SUBSCRIPTIONS__SHARE_PARAMETERIZED) turns the feature off.
  • Standing-query re-evaluations now reuse a compiled plan until the persistent rules change. The cache in src/storage_engine/snapshot.rs evicts one plan at the cap instead of clearing all of them. An evaluated round can drop its snapshot. Join planning (src/join_planning) no longer joins a relation before the one it shares variables with.
  • Adds a perf-gate session-scale benchmark on the voice-agent pack, run with scripts/bench-sessions.sh / make bench-sessions. Also adds unit, plan-cache and standing-query integration tests, and documents the feature in the configuration and WebSocket API guides.

Risk Assessment

⚠️ Medium: The latest fix closes the stale round path. A sibling stale own-cost path can still seed the latency guard with costs from the old rules after a rule change, which can cause a latency regression without any error, but it is bounded and a follow-up can fix it.

Testing

I built the server at the target commit under the 16G cap and ran it as a real process on 4 CPUs in disposable data and home directories. A Node WebSocket driver ran 40 session-bound subscriptions through targeted writes, cross-session moves, retractions, in-flight rule changes and a recursive bound pack, with sharing on and off. Every session's accumulated deltas always equaled its one-off query, and no session received another session's rows. With sharing on, write-to-delta was about 22 ms p50 and server CPU about half of the sharing-off run. The first rule-change run failed because of my driver: it stopped reading once the rows happened to match, while valid interim deltas were still queued. After fixing the driver to drain until quiet, three runs with sharing on and one with it off passed. The stale-rules guard's internal decision isn't visible on the running server, so I didn't validate it live; only unit tests cover it. The targeted stale-rules unit tests and the parameterized integration tests pass. No benchmarks or full suite were run, per the recorded instructions. All servers are stopped, the temporary directories are removed, and the worktree is clean.

  • Live validation: ✅ go - 4 of 5 scenarios driven live against the product
Scenario Result Live Evidence
40 session-bound subscriptions on one graph: each write produces a delta only for its session, and every view equals its one-off query ✅ pass live scenario-shared-40-sessions.log (190 oracle checks, quiet checks on the other 39 sessions, p50 21.6 ms / p99 23 ms)
Sharing switched off with INPUTLAYER_SUBSCRIPTIONS__SHARE_PARAMETERIZED=false: same exact results, so sharing is what cuts latency and CPU ✅ pass live scenario-sharing-off-40-sessions.log (p50 192 ms, server CPU 46.6 s vs 20.7 s with sharing on)
Adversarial: rule add, clause remove and rule delete race with writes while rounds are in flight; every view settles at its one-off query, with no duplicate insert, absent retract or out-of-order revi… ✅ pass live scenario-rule-changes-in-flight-run1..3.log, scenario-rule-changes-in-flight-sharing-off.log
Bound recursive subscriptions (?reach("nK", Y)) stay exact through edge add, remove and cut ✅ pass live scenario-recursive-bound.log (180 oracle checks)
Stale-rules guard: own costs and round verdicts measured under older rules never change sharing state ⏸️ untested no The prior payload did not establish a live result for this scenario. The cost and judged-rules state is internal, and the running server doesn't expose it (it has no metrics endpoint), so the guard's…
Evidence: 40 sessions, sharing on: exact deltas reach only the written session, ~22 ms p50
server_pid=2769433
19:19:59.683 result kg t1791141599677
19:20:16.320 subscribed 40 session views; initial rows each 1
19:20:17.847 warm-up converged
19:20:19.171 targeted writes: each delta reached only its session; latency ms {"p50":21.6,"p99":23,"max":23}
19:20:19.631 move/retract converged; s1 holds only s1 rows; s2 rows 5
SUMMARY {"scenario":"shared","N":40,"checks":190,"targeted_write_to_delta_ms":{"p50":21.6,"p99":23,"max":23},"ok":true}
server_cpu_seconds=20.680 driver_exit=0
stops_sharing_log_lines=0
Evidence: 40 sessions, sharing off by env override: same correctness, ~192 ms p50, about 2x CPU
server_pid=2771565
19:20:21.644 result kg t1791141621639
19:20:37.689 subscribed 40 session views; initial rows each 1
19:20:40.106 warm-up converged
19:20:52.600 targeted writes: each delta reached only its session; latency ms {"p50":192.3,"p99":379.7,"max":379.7}
19:20:53.485 move/retract converged; s1 holds only s1 rows; s2 rows 5
SUMMARY {"scenario":"shared","N":40,"checks":190,"targeted_write_to_delta_ms":{"p50":192.3,"p99":379.7,"max":379.7},"ok":true}
server_cpu_seconds=46.630 driver_exit=0
stops_sharing_log_lines=0
Evidence: Rule changes interleaved with writes during in-flight rounds (run 1)
server_pid=2834044
19:23:37.004 result kg t1791141816995
19:23:53.991 subscribed 40 session views; initial rows each 1
19:23:55.610 warm-up converged
19:23:57.075 targeted writes: each delta reached only its session; latency ms {"p50":25,"p99":50.7,"max":50.7}
19:23:57.538 move/retract converged; s1 holds only s1 rows; s2 rows 5
19:23:57.806 iter 0 result [["Rule 'view' registered."]]
19:23:57.807 iter 0 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:23:58.069 iter 1 result [["Inserted 1 fact(s) into 'extra'."]]
19:23:58.070 iter 1 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:23:58.084 iter 2 result [["Clause 1 removed from rule 'view'."]]
19:23:58.084 iter 2 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:23:58.090 iter 3 result [["Rule 'view' registered."]]
19:23:58.090 iter 3 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:23:58.097 iter 4 result [["Inserted 1 fact(s) into 'extra'."]]
19:23:58.097 iter 4 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:23:58.197 iter 5 result [["Rule 'view' deleted (last clause removed)."]]
19:23:58.197 iter 5 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:23:58.220 iter 6 result [["Rule 'view' registered."]]
19:23:58.220 iter 6 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:23:58.284 iter 7 result [["Inserted 1 fact(s) into 'extra'."]]
19:23:58.284 iter 7 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:23:58.333 iter 8 result [["Rule 'view' deleted (last clause removed)."]]
19:23:58.333 iter 8 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:23:58.378 iter 9 result [["Rule 'view' registered."]]
19:23:58.378 iter 9 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:23:58.435 iter 10 result [["Inserted 1 fact(s) into 'extra'."]]
19:23:58.435 iter 10 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:23:58.483 iter 11 result [["Rule 'view' deleted (last clause removed)."]]
19:23:58.483 iter 11 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:24:58.664 drained 58 deltas after the churn (each applied strictly: no dup insert, no absent retract, increasing revision)
19:24:58.665 final rules [["No rules defined."]]
19:24:58.665 after 12 interleaved rule-change/write rounds every session view equals its one-off query
19:24:59.116 post-rule-change targeted write converged, no stray pushes
SUMMARY {"scenario":"rules","N":40,"checks":270,"targeted_write_to_delta_ms":{"p50":25,"p99":50.7,"max":50.7},"ok":true}
server_cpu_seconds=23.470 driver_exit=0
stops_sharing_log_lines=0
Evidence: Rule changes interleaved with writes during in-flight rounds (run 2)
server_pid=2856980
19:25:03.436 result kg t1791141903308
19:25:22.706 subscribed 40 session views; initial rows each 1
19:25:24.257 warm-up converged
19:25:25.618 targeted writes: each delta reached only its session; latency ms {"p50":22.4,"p99":26,"max":26}
19:25:26.090 move/retract converged; s1 holds only s1 rows; s2 rows 5
19:25:26.149 iter 0 result [["Rule 'view' registered."]]
19:25:26.150 iter 0 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:25:26.585 iter 1 result [["Inserted 1 fact(s) into 'extra'."]]
19:25:26.585 iter 1 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:25:26.618 iter 2 result [["Clause 1 removed from rule 'view'."]]
19:25:26.618 iter 2 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:25:26.691 iter 3 result [["Rule 'view' registered."]]
19:25:26.691 iter 3 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:25:26.701 iter 4 result [["Inserted 1 fact(s) into 'extra'."]]
19:25:26.701 iter 4 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:25:26.713 iter 5 result [["Rule 'view' deleted (last clause removed)."]]
19:25:26.713 iter 5 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:25:26.758 iter 6 result [["Rule 'view' registered."]]
19:25:26.758 iter 6 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:25:26.812 iter 7 result [["Inserted 1 fact(s) into 'extra'."]]
19:25:26.812 iter 7 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:25:26.855 iter 8 result [["Rule 'view' deleted (last clause removed)."]]
19:25:26.856 iter 8 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:25:26.899 iter 9 result [["Rule 'view' registered."]]
19:25:26.899 iter 9 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:25:26.956 iter 10 result [["Inserted 1 fact(s) into 'extra'."]]
19:25:26.957 iter 10 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:25:27.013 iter 11 result [["Rule 'view' deleted (last clause removed)."]]
19:25:27.013 iter 11 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:26:27.197 drained 59 deltas after the churn (each applied strictly: no dup insert, no absent retract, increasing revision)
19:26:27.198 final rules [["No rules defined."]]
19:26:27.198 after 12 interleaved rule-change/write rounds every session view equals its one-off query
19:26:27.637 post-rule-change targeted write converged, no stray pushes
SUMMARY {"scenario":"rules","N":40,"checks":270,"targeted_write_to_delta_ms":{"p50":22.4,"p99":26,"max":26},"ok":true}
server_cpu_seconds=24.650 driver_exit=0
stops_sharing_log_lines=0
Evidence: Rule changes interleaved with writes during in-flight rounds (run 3)
server_pid=2886691
19:26:29.700 result kg t1791141989694
19:26:46.849 subscribed 40 session views; initial rows each 1
19:26:48.484 warm-up converged
19:26:49.864 targeted writes: each delta reached only its session; latency ms {"p50":22,"p99":30.4,"max":30.4}
19:26:50.354 move/retract converged; s1 holds only s1 rows; s2 rows 5
19:26:50.402 iter 0 result [["Rule 'view' registered."]]
19:26:50.402 iter 0 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:26:50.859 iter 1 result [["Inserted 1 fact(s) into 'extra'."]]
19:26:50.860 iter 1 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:26:50.872 iter 2 result [["Clause 1 removed from rule 'view'."]]
19:26:50.872 iter 2 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:26:50.878 iter 3 result [["Rule 'view' registered."]]
19:26:50.878 iter 3 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:26:50.884 iter 4 result [["Inserted 1 fact(s) into 'extra'."]]
19:26:50.884 iter 4 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:26:50.957 iter 5 result [["Rule 'view' deleted (last clause removed)."]]
19:26:50.957 iter 5 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:26:51.014 iter 6 result [["Rule 'view' registered."]]
19:26:51.014 iter 6 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:26:51.068 iter 7 result [["Inserted 1 fact(s) into 'extra'."]]
19:26:51.068 iter 7 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:26:51.118 iter 8 result [["Rule 'view' deleted (last clause removed)."]]
19:26:51.118 iter 8 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:26:51.169 iter 9 result [["Rule 'view' registered."]]
19:26:51.169 iter 9 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:26:51.227 iter 10 result [["Inserted 1 fact(s) into 'extra'."]]
19:26:51.227 iter 10 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:26:51.282 iter 11 result [["Rule 'view' deleted (last clause removed)."]]
19:26:51.282 iter 11 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:27:51.463 drained 59 deltas after the churn (each applied strictly: no dup insert, no absent retract, increasing revision)
19:27:51.464 final rules [["No rules defined."]]
19:27:51.464 after 12 interleaved rule-change/write rounds every session view equals its one-off query
19:27:51.910 post-rule-change targeted write converged, no stray pushes
SUMMARY {"scenario":"rules","N":40,"checks":270,"targeted_write_to_delta_ms":{"p50":22,"p99":30.4,"max":30.4},"ok":true}
server_cpu_seconds=23.380 driver_exit=0
stops_sharing_log_lines=0
Evidence: Rule changes in flight with sharing off (control)
server_pid=2931841
19:27:54.033 result kg t1791142074024
19:28:11.437 subscribed 40 session views; initial rows each 1
19:28:13.769 warm-up converged
19:28:25.314 targeted writes: each delta reached only its session; latency ms {"p50":169.3,"p99":374.4,"max":374.4}
19:28:26.095 move/retract converged; s1 holds only s1 rows; s2 rows 5
19:28:26.145 iter 0 result [["Rule 'view' registered."]]
19:28:26.145 iter 0 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:28:26.530 iter 1 result [["Inserted 1 fact(s) into 'extra'."]]
19:28:26.530 iter 1 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:28:26.894 iter 2 result [["Clause 1 removed from rule 'view'."]]
19:28:26.894 iter 2 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:28:26.946 iter 3 result [["Rule 'view' registered."]]
19:28:26.946 iter 3 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:28:26.985 iter 4 result [["Inserted 1 fact(s) into 'extra'."]]
19:28:26.985 iter 4 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:28:27.046 iter 5 result [["Rule 'view' deleted (last clause removed)."]]
19:28:27.046 iter 5 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:28:27.113 iter 6 result [["Rule 'view' registered."]]
19:28:27.113 iter 6 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:28:27.170 iter 7 result [["Inserted 1 fact(s) into 'extra'."]]
19:28:27.170 iter 7 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:28:27.220 iter 8 result [["Rule 'view' deleted (last clause removed)."]]
19:28:27.220 iter 8 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:28:27.263 iter 9 result [["Rule 'view' registered."]]
19:28:27.263 iter 9 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:28:27.333 iter 10 result [["Inserted 1 fact(s) into 'extra'."]]
19:28:27.333 iter 10 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:28:27.417 iter 11 result [["Rule 'view' deleted (last clause removed)."]]
19:28:27.417 iter 11 result [["Inserted 1 fact(s) into 'item'."],["Inserted 1 fact(s) into 'kind'."]]
19:29:27.585 drained 60 deltas after the churn (each applied strictly: no dup insert, no absent retract, increasing revision)
19:29:27.586 final rules [["No rules defined."]]
19:29:27.586 after 12 interleaved rule-change/write rounds every session view equals its one-off query
19:29:28.015 post-rule-change targeted write converged, no stray pushes
SUMMARY {"scenario":"rules","N":40,"checks":270,"targeted_write_to_delta_ms":{"p50":169.3,"p99":374.4,"max":374.4},"ok":true}
server_cpu_seconds=48.640 driver_exit=0
stops_sharing_log_lines=0
Evidence: First rule-change attempt: failed from a driver bug (stopped reading before the queued interim deltas arrived)
server_pid=2775538
19:20:55.512 result kg t1791141655506
19:21:11.873 subscribed 40 session views; initial rows each 1
19:21:13.366 warm-up converged
19:21:14.712 targeted writes: each delta reached only its session; latency ms {"p50":22.2,"p99":29.8,"max":29.8}
19:21:15.181 move/retract converged; s1 holds only s1 rows; s2 rows 5
19:21:16.065 final rules [["No rules defined."]]
19:21:16.065 after 12 interleaved rule-change/write rounds every session view equals its one-off query
FAIL ?view("s4", X, "a"): unexpected push {"type":"subscription_delta","subscription":"s","generation":1,"knowledge_graph":"t1791141655506","seq":8,"revision":55,"columns":["s4","X","a"],"inserted":[["s4",9001,"a"]],"retracted":[],"_t":21031.882549}
server_cpu_seconds=22.230 driver_exit=1
stops_sharing_log_lines=0
Evidence: Bound recursive views stay exact
server_pid=2777558
19:21:18.452 result kg t1791141678448
19:21:26.639 subscribed 20 bound recursive views; n1 reaches 39
19:21:31.660 recursive views exact after edge add/remove and cut; n1 reaches 19 all-converged ms {"p50":599.3,"max":654.5}
SUMMARY {"scenario":"recursive","N":40,"checks":180,"all_converged_ms":{"p50":599.3,"max":654.5},"ok":true}
server_cpu_seconds=15.550 driver_exit=0
stops_sharing_log_lines=0
Evidence: WebSocket driver used for all scenarios
// Drives a real inputlayer-server: N session-bound standing subscriptions, writes,
// rule changes; checks every subscriber's accumulated rows equal a one-off query.
const [port, scenario, nArg] = [process.argv[2], process.argv[3], process.argv[4]];
const N = Number(nArg || 40);
const PW = 'test-admin-pw';
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a);

class C {
  constructor() { this.q = []; this.waiters = []; this.pushes = []; this.pushWaiters = []; }
  async open(kg) {
    this.ws = new WebSocket(`ws://127.0.0.1:${port}/ws?kg=${kg}`);
    await new Promise((r, j) => { this.ws.onopen = r; this.ws.onerror = j; });
    this.ws.onmessage = (ev) => {
      const m = JSON.parse(ev.data);
      if (String(m.type).startsWith('subscription_')) {
        m._t = performance.now();
        const w = this.pushWaiters.shift(); if (w) w(m); else this.pushes.push(m);
      } else if (['result', 'error', 'authenticated'].includes(m.type)) {
        const w = this.waiters.shift(); if (w) w(m); else this.q.push(m);
      }
    };
    const a = await this.req({ type: 'login', username: 'admin', password: PW });
    if (a.type !== 'authenticated') throw new Error(JSON.stringify(a));
    return this;
  }
  req(o) { this.ws.send(JSON.stringify(o)); return this.reply(); }
  reply() { const m = this.q.shift(); return m ? Promise.resolve(m) : new Promise(r => this.waiters.push(r)); }
  exec(p) { return this.req({ type: 'execute', program: p }); }
  nextPush(ms) {
    const m = this.pushes.shift(); if (m) return Promise.resolve(m);
    return new Promise(r => { const t = setTimeout(() => { this.pushWaiters = this.pushWaiters.filter(x => x !== f); r(null); }, ms); const f = (m) => { clearTimeout(t); r(m); }; this.pushWaiters.push(f); });
  }
  close() { this.ws.close(); }
}

const key = (r) => JSON.stringify(r);
class Member {
  constructor(c, query) { this.c = c; this.query = query; this.rows = new Set(); this.rev = 0; this.deltas = 0; }
  async sub() {
    const r = await this.c.exec(`.subscribe s ${this.query}`);
    if (r.type !== 'result') throw new Error(`subscribe ${this.query}: ${JSON.stringify(r)}`);
    for (const row of r.rows) this.rows.add(key(row)); this.rev = r.subscribed.revision;
  }
  async oracle() { const r = await this.c.exec(this.query); if (r.type !== 'result') throw new Error(JSON.stringify(r)); return new Set(r.rows.map(key)); }
  apply(p) {
    if (p.type !== 'subscription_delta') throw new Error(`${this.query}: ${JSON.stringify(p)}`);
    if (!(p.revision > this.rev)) throw new Error(`${this.query}: non-increasing revision ${JSON.stringify(p)}`);
    this.rev = p.revision; this.deltas++;
    for (const r of p.retracted) if (!this.rows.delete(key(r))) throw new Error(`${this.query}: retract of absent ${key(r)}`);
    for (const r of p.inserted) { if (this.rows.has(key(r))) throw new Error(`${this.query}: dup insert ${key(r)}`); this.rows.add(key(r)); }
    return p._t;
  }
  async converge(timeout = 20000) {
    const want = await this.oracle();
    const eq = () => this.rows.size === want.size && [...want].every(x => this.rows.has(x));
    const end = Date.now() + timeout;
    while (!eq()) {
      const p = await this.c.nextPush(Math.max(1, end - Date.now()));
      if (!p) throw new Error(`${this.query}: stuck at ${[...this.rows]} want ${[...want]}`);
      this.apply(p);
    }
    // anything else already queued must also keep us at the oracle
    return want.size;
  }
  async quiet(ms) { const p = await this.c.nextPush(ms); if (p) {
    const held = [...this.rows]; let extra = [p]; for (;;) { const q = await this.c.nextPush(2000); if (!q) break; extra.push(q); }
    const want = [...(await this.oracle())];
    let applied = null; try { for (const x of extra) this.apply(x); applied = [...this.rows]; } catch (e) { applied = 'apply error: ' + e.message; }
    throw new Error(`${this.query}: unexpected push(es) ${JSON.stringify(extra)}\n held=${JSON.stringify(held)} rev=${this.rev}\n oracle_now=${JSON.stringify(want)}\n after_applying=${JSON.stringify(applied)}`); } }
}

function pct(a, p) { const s = [...a].sort((x, y) => x - y); return s[Math.min(s.length - 1, Math.floor(p * s.length))]; }

async function main() {
  const admin = await new C().open('default');
  const kg = `t${Date.now()}`;
  log((await admin.exec(`.kg create ${kg}`)).type, 'kg', kg);
  const w = await new C().open(kg);
  const ok = async (p) => { const r = await w.exec(p); if (r.type !== 'result') throw new Error(`${p}: ${JSON.stringify(r)}`); return r; };
  const members = [];
  const summary = { scenario, N, checks: 0 };

  if (scenario === 'shared' || scenario === 'rules') {
    await ok('+view(S, X, Kind) <- item(S, X), kind(X, Kind)');
    let facts = [];
    for (let s = 1; s <= N; s++) facts.push(`("s${s}", ${s})`);
    await ok(`+item[${facts.join(', ')}]`);
    await ok(`+kind[${Array.from({ length: N }, (_, i) => `(${i + 1}, "a")`).join(', ')}]`);
    for (let s = 1; s <= N; s++) { const m = new Member(await new C().open(kg), `?view("s${s}", X, "a")`); await m.sub(); members.push(m); }
    log('subscribed', members.length, 'session views; initial rows each', members[0].rows.size);
    // warm-up writes touching all sessions (plan cache + own cost samples)
    for (let k = 0; k < 3; k++) {
      await ok(`+item[${Array.from({ length: N }, (_, i) => `("s${i + 1}", ${1000 + k})`).join(', ')}]\n+kind(${1000 + k}, "a")`);
      for (const m of members) { await m.converge(); summary.checks++; }
    }
    log('warm-up converged');
    // targeted writes: only one session changes; it gets its delta, others stay quiet
    const lat = [];
    for (let i = 0; i < 30; i++) {
      const s = 1 + (i * 7) % N; const x = 5000 + i;
      const t0 = performance.now();
      await ok(`+item("s${s}", ${x})\n+kind(${x}, "a")`);
      const m = members[s - 1];
      const p = await m.c.nextPush(20000); if (!p) throw new Error(`no delta for s${s}`);
      lat.push(m.apply(p) - t0);
      if (!p.inserted.some(r => JSON.stringify(r).includes(String(x)))) throw new Error(`delta lacks row ${x}: ${JSON.stringify(p)}`);
      await m.converge(); summary.checks++;
    }
    for (const [i, m] of members.entries()) await m.quiet(i === 0 ? 300 : 1);
    summary.targeted_write_to_delta_ms = { p50: +pct(lat, .5).toFixed(1), p99: +pct(lat, .99).toFixed(1), max: +Math.max(...lat).toFixed(1) };
    log('targeted writes: each delta reached only its session; latency ms', JSON.stringify(summary.targeted_write_to_delta_ms));
    // a write moving a row between sessions + a retraction
    await ok('-item("s1", 1)\n+item("s2", 1)');
    await ok('-kind(2, "a")\n+kind(2, "b")');
    for (const m of members) { await m.converge(); summary.checks++; }
    if ([...members[0].rows].some(r => !r.includes('"s1"'))) throw new Error('s1 got foreign rows');
    log('move/retract converged; s1 holds only s1 rows; s2 rows', members[1].rows.size);
  }

  if (scenario === 'rules') {
    // adversarial: rule changes interleaved with writes while rounds are in flight
    for (let i = 0; i < 12; i++) {
      const s = 1 + (i * 3) % N;
      const writes = [];
      if (i % 3 === 0) writes.push(w.exec(`+view(S, X, Kind) <- extra(S, X, Kind)`));
      if (i % 3 === 1) writes.push(w.exec(`+extra("s${s}", ${9000 + i}, "a")`));
      if (i % 3 === 2) writes.push(w.exec(`.rule remove view 1`));
      writes.push(w.exec(`+item("s${s}", ${7000 + i})\n+kind(${7000 + i}, "a")`));
      const rs = await Promise.all(writes);
      for (const r of rs) log('iter', i, r.type, JSON.stringify(r.rows ?? r.message ?? r).slice(0, 160));
      // don't wait for convergence between iterations: keep rounds in flight
    }
    // drain every delta until each stream is quiet, then the accumulated rows must equal the one-off query
    let interim = 0;
    for (const m of members) { const d0 = m.deltas; for (;;) { const p = await m.c.nextPush(1500); if (!p) break; m.apply(p); } interim += m.deltas - d0;
      const want = await m.oracle(); if (m.rows.size !== want.size || ![...want].every(x => m.rows.has(x))) throw new Error(`${m.query}: settled at ${[...m.rows]} but one-off query gives ${[...want]}`); summary.checks++; }
    log('drained', interim, 'deltas after the churn (each applied strictly: no dup insert, no absent retract, increasing revision)');
    const rl = await w.exec('.rule list view'); log('final rules', JSON.stringify(rl.rows ?? rl).slice(0, 300));
    log('after 12 interleaved rule-change/write rounds every session view equals its one-off query');
    // and a write after the dust settles is still exact and targeted
    await ok('+item("s5", 8888)\n+kind(8888, "a")');
    for (const m of members) { await m.converge(); summary.checks++; }
    for (const [i, m] of members.entries()) await m.quiet(i === 0 ? 300 : 1);
    log('post-rule-change targeted write converged, no stray pushes');
  }

  if (scenario === 'recursive') {
    await ok('+reach(A, B) <- edge(A, B)');
    await ok('+reach(A, C) <- reach(A, B), edge(B, C)');
    const e = []; for (let i = 1; i < N; i++) e.push(`("n${i}", "n${i + 1}")`);
    await ok(`+edge[${e.join(', ')}]`);
    for (let s = 1; s <= N; s += Math.max(1, Math.floor(N / 20))) { const m = new Member(await new C().open(kg), `?reach("n${s}", Y)`); await m.sub(); members.push(m); }
    log('subscribed', members.length, 'bound recursive views; n1 reaches', members[0].rows.size);
    const lat = [];
    for (let i = 0; i < 8; i++) {
      const t0 = performance.now();
      await ok(i % 2 === 0 ? `+edge("n${N}", "x${i}")` : `-edge("n${N}", "x${i - 1}")`);
      for (const m of members) { await m.converge(); summary.checks++; }
      lat.push(performance.now() - t0);
    }
    await ok(`-edge("n${Math.floor(N / 2)}", "n${Math.floor(N / 2) + 1}")`);
    for (const m of members) { await m.converge(); summary.checks++; }
    summary.all_converged_ms = { p50: +pct(lat, .5).toFixed(1), max: +Math.max(...lat).toFixed(1) };
    log('recursive views exact after edge add/remove and cut; n1 reaches', members[0].rows.size, 'all-converged ms', JSON.stringify(summary.all_converged_ms));
  }

  summary.ok = true;
  console.log('SUMMARY ' + JSON.stringify(summary));
  for (const m of members) m.c.close(); w.close(); admin.close();
}
main().then(() => process.exit(0), (e) => { console.log('FAIL', e.message); process.exit(1); });
Evidence: Server launcher (memory cap, taskset, env overrides)
#!/usr/bin/env bash
# run.sh <name> <scenario> <N> [extra env...]
set -u
NAME=$1; SC=$2; N=$3; shift 3
D=/tmp/il-test-01M43/$NAME; rm -rf $D; mkdir -p $D
PORT=$(python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1])')
BIN=~/.no-mistakes/worktrees/9eea230c3aae/01M43ZSG4NFCYABB10K7JVA5Z9/target/debug/inputlayer-server
(cd $D && HOME=$D env INPUTLAYER_ADMIN_PASSWORD=test-admin-pw INPUTLAYER_HTTP__RATE_LIMIT__WS_MAX_MESSAGES_PER_SEC=0 INPUTLAYER_HTTP__RATE_LIMIT__PER_IP_MAX_RPS=0 INPUTLAYER_HTTP__GUI__ENABLED=false INPUTLAYER_TRACE_LEVEL="info,inputlayer::protocol::subscription=debug" "$@" \
  systemd-run --user --scope -q -p MemoryMax=16G -p MemorySwapMax=0 taskset -c 0-3 $BIN --port $PORT --data-dir $D/store > $D/server.log 2>&1) &
for i in $(seq 100); do curl -s -o /dev/null http://127.0.0.1:$PORT/ && break; sleep 0.2; done
PID=$(pgrep -f "^$BIN --port $PORT " | head -1); echo server_pid=$PID
c0=$(awk "{print \$14+\$15}" /proc/$PID/stat)
node /tmp/il-test-01M43/drive.mjs $PORT $SC $N; RC=$?
c1=$(awk "{print \$14+\$15}" /proc/$PID/stat)
echo "server_cpu_seconds=$(echo "($c1-$c0)/100" | bc -l | cut -c1-6) driver_exit=$RC"
grep -c subscription_family_stops_sharing $D/server.log | sed 's/^/stops_sharing_log_lines=/'
kill $PID; sleep 0.5; exit $RC
Evidence: Parameterized unit tests (stale-rules guard)
test protocol::subscription::parameterized::tests::a_round_shares_while_no_slower_than_the_views_own_evaluations_in_parallel ... ok
test protocol::subscription::parameterized::tests::costs_are_smoothed_so_one_outlier_does_not_decide ... ok
test protocol::subscription::parameterized::tests::integer_parameters_match_rows_as_the_engine_matches_the_constant ... ok
test protocol::subscription::parameterized::tests::probes_wait_a_number_of_commits_that_doubles_with_each_failed_probe ... ok
test protocol::subscription::parameterized::tests::queries_without_liftable_constants_or_in_other_forms_are_not_lifted ... ok
test protocol::subscription::parameterized::tests::projection_drops_parameters_first_seen_in_the_body ... ok
test protocol::subscription::parameterized::tests::negations_comparisons_and_other_constants_are_part_of_the_shape ... ok
test protocol::subscription::parameterized::tests::an_ambiguous_row_fails_the_partition ... ok
test protocol::subscription::parameterized::tests::queries_differing_only_in_constants_share_a_shape ... ok
test protocol::subscription::parameterized::tests::equal_strings_share_a_parameter_and_repeated_integers_stay ... ok
test protocol::subscription::parameterized::tests::a_lifted_result_partitions_into_each_bindings_projected_rows ... ok
test protocol::subscription::parameterized::tests::rounds::a_probe_runs_while_queries_hold_every_compute_permit ... ok
test protocol::subscription::parameterized::tests::rounds::shapes_of_different_parameter_kinds_are_different_families ... ok
test protocol::subscription::parameterized::tests::rounds::a_probe_too_slow_to_share_never_makes_a_view_wait ... ok
test protocol::subscription::parameterized::tests::rounds::an_own_cost_from_before_a_rule_change_is_dropped ... ok
test protocol::subscription::parameterized::tests::rounds::a_slow_round_stops_sharing_even_when_it_compiled_its_plan ... ok
test protocol::subscription::parameterized::tests::rounds::without_permits_to_probe_a_family_shares_by_its_shape ... ok
test protocol::subscription::parameterized::tests::rounds::a_round_from_before_a_rule_change_does_not_share_again ... ok
test protocol::subscription::parameterized::tests::rounds::views_share_a_round_that_lets_its_snapshot_go ... ok
test protocol::subscription::parameterized::tests::rounds::a_family_whose_views_fit_on_the_compute_permits_never_shares ... ok
test protocol::subscription::parameterized::tests::rounds::a_rule_change_starts_the_costs_over_and_judges_the_shape_again ... ok
test protocol::subscription::parameterized::tests::rounds::without_permits_to_probe_a_recursion_bound_family_never_shares ... ok
test protocol::subscription::parameterized::tests::rounds::a_recursion_bound_family_never_evaluates_its_lifted_query ... ok
test result: ok. 23 passed; 0 failed; 0 ignored; 0 measured; 3037 filtered out; finished in 0.47s
Evidence: Parameterized end-to-end integration tests
test parameterized::sharing_can_be_switched_off ... ok
test parameterized::results_over_the_row_cap_fail_as_the_views_own_query_does ... ok
test parameterized::a_family_of_one_binding_evaluates_on_its_own ... ok
test parameterized::bound_subscriptions_share_one_evaluation_and_get_only_their_rows ... ok
test parameterized::every_subscriber_follows_its_own_query_through_mixed_writes ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 20 filtered out; finished in 15.72s

Pipeline

Updates from git push no-mistakes

... (4 earlier update rounds omitted to keep the PR body within GitHub's 65536-char limit; full history is in the run log.)

⚠️ **Review** - 3 issues (1 warning, 2 infos)

🔧 Fix applied.
2 warnings still open:

  • ⚠️ src/config.rs:514 - Simplification: the new config option http.rate_limit.subscription_share_parameterized (default true, read at src/protocol/subscription/connection.rs:98, documented in config.example.toml, docs/guides/configuration.md and websocket-api.mdx) is an added mode. The intent (fix session-scale cost without regressing latency) doesn't require it, because the family's own latency guard already turns sharing off per shape. Smallest remedy: remove the option, unless the author wants it as an operator kill switch for a default-on heuristic.
  • ⚠️ src/config.rs:800 - The second fix round (8dedd65) moved the sharing kill switch into a new top-level [subscriptions] section, but Config::env_source only passes INPUTLAYER_* keys from the sections listed in SECTIONS = [&#34;storage&#34;, &#34;optimization&#34;, &#34;logging&#34;, &#34;http&#34;] (src/config.rs:800). As a result, INPUTLAYER_SUBSCRIPTIONS__SHARE_PARAMETERIZED=false is silently dropped and sharing stays on. Before the move, the same switch could be set through INPUTLAYER_HTTP__RATE_LIMIT__SUBSCRIPTION_SHARE_PARAMETERIZED. docs/guides/configuration.md also says "All config options can be overridden with environment variables". This matters most in container deployments, where an operator would most likely flip the default-on heuristic through an environment variable, and it fails with no error. Fix: add "subscriptions" to SECTIONS in env_source. Same class, also at src/config.rs:786: the seed() minimal config has no &#34;subscriptions&#34;: {} entry, even though its comment says every nested section must appear there. This is harmless today because SubscriptionsConfig::default() matches the field default, but add the entry for consistency.

🔧 Fix applied.
✅ Re-checked - no issues remain.

  • 🚨 src/protocol/subscription/parameterized.rs:617 - On any host with 3 or more compute permits (the default on a typical server), Family::probe never consults binds_recursion. That check only runs on the compute_permits() &lt; MIN_PERMITS_FOR_PROBES branch (lines 618-624). So a family of recursion-bound views such as ?reach(&#34;n1&#34;, Y), ?reach(&#34;n2&#34;, Y), … with more bindings than permits makes the server evaluate the lifted, unbound ?reach(_L0, Y) in a background probe. That is the full transitive closure, which is quadratic or worse. Base never computes it: each bound view stays restricted by Magic Sets. The test a_probe_too_slow_to_share_never_makes_a_view_wait (tests.rs:379) shows this happening on a 400-node chain. The probe's only bounds are query_timeout_ms (default 30 s) and max_result_rows. Neither caps the fixpoint's intermediate memory, and there is no per-query memory limit yet (issue Engine: per-graph memory budget and per-query memory limit, refuse with resource_exhausted (EN-9) #262). The attached verification plan names exactly this unbound ?reach closure as what OOM-crashed the devbox (~115 GiB), and its adversarial evidence covers only 2 permits, where the shape check prevents the probe. After a failed probe the family re-probes after 256×bindings own evaluations, with backoff, so the exposure repeats for the life of the subscriptions. Smallest remedy: apply the existing binds_recursion(&amp;shape, &amp;snapshot.rules) check before any probe, whatever the permit count, so recursion-bound shapes are decided by shape and never probed. Doing that reverses the earlier fix round 8ac7d51's choice to decide by shape only on small hosts, so the author needs to confirm it.
  • ⚠️ src/protocol/subscription/parameterized.rs:547 - The latency guard compares against cost estimates measured under the old rules after a rule change. On a rule change, every view sees a validated mismatch and evaluates its own query. That evaluation compiles a plan, so record_own(None) leaves own_cost_us unchanged (line 600). sharing and shared_cost_us are not reset either. On the next commit the views read a round under the new rules, and keeps_sharing (line 551) judges it against an own cost measured under the old rules. Own cost is refreshed only by one sample every SAMPLE_EVERY=64 rounds, at 1/8 EWMA weight. Concrete case: 200 bindings on 4 permits, old own cost 5 ms, so the unshared bound is 250 ms. A rule change makes bound evaluations cheap (for example, the read relation becomes recursive so Magic Sets restricts it to ~0.1 ms) while the lifted round takes 200 ms. The family keeps sharing at 200 ms per delta against ~5 ms unshared, about 40× slower, until two own samples (~128 commits) pull the average under the threshold. That violates "latency must not regress" with no error. The defect is in the guard that the earlier fix round 322e26b introduced (the EWMA replacing STOP_MARGIN_PERCENT); that round did not handle rule changes. Fix: tie the costs to the rules they were measured under. When a round's or own evaluation's PersistentRules differs from the last-measured one, reset own_cost_us/shared_cost_us and stop sharing without counting a failed probe (do not bump stops), so the family re-probes once a plan-cached own cost under the new rules is known.

🔧 Fix applied.
3 issues (1 error, 2 warnings) still open:

  • 🚨 src/protocol/subscription/parameterized.rs:617 - On any host with 3 or more compute permits (the default on a typical server), Family::probe never consults binds_recursion. That check only runs on the compute_permits() &lt; MIN_PERMITS_FOR_PROBES branch (lines 618-624). So a family of recursion-bound views such as ?reach(&#34;n1&#34;, Y), ?reach(&#34;n2&#34;, Y), … with more bindings than permits makes the server evaluate the lifted, unbound ?reach(_L0, Y) in a background probe. That is the full transitive closure, which is quadratic or worse. Base never computes it: each bound view stays restricted by Magic Sets. The test a_probe_too_slow_to_share_never_makes_a_view_wait (tests.rs:379) shows this happening on a 400-node chain. The probe's only bounds are query_timeout_ms (default 30 s) and max_result_rows. Neither caps the fixpoint's intermediate memory, and there is no per-query memory limit yet (issue Engine: per-graph memory budget and per-query memory limit, refuse with resource_exhausted (EN-9) #262). The attached verification plan names exactly this unbound ?reach closure as what OOM-crashed the devbox (~115 GiB), and its adversarial evidence covers only 2 permits, where the shape check prevents the probe. After a failed probe the family re-probes after 256×bindings own evaluations, with backoff, so the exposure repeats for the life of the subscriptions. Smallest remedy: apply the existing binds_recursion(&amp;shape, &amp;snapshot.rules) check before any probe, whatever the permit count, so recursion-bound shapes are decided by shape and never probed. Doing that reverses the earlier fix round 8ac7d51's choice to decide by shape only on small hosts, so the author needs to confirm it.
  • ⚠️ src/protocol/subscription/parameterized.rs:547 - The latency guard compares against cost estimates measured under the old rules after a rule change. On a rule change, every view sees a validated mismatch and evaluates its own query. That evaluation compiles a plan, so record_own(None) leaves own_cost_us unchanged (line 600). sharing and shared_cost_us are not reset either. On the next commit the views read a round under the new rules, and keeps_sharing (line 551) judges it against an own cost measured under the old rules. Own cost is refreshed only by one sample every SAMPLE_EVERY=64 rounds, at 1/8 EWMA weight. Concrete case: 200 bindings on 4 permits, old own cost 5 ms, so the unshared bound is 250 ms. A rule change makes bound evaluations cheap (for example, the read relation becomes recursive so Magic Sets restricts it to ~0.1 ms) while the lifted round takes 200 ms. The family keeps sharing at 200 ms per delta against ~5 ms unshared, about 40× slower, until two own samples (~128 commits) pull the average under the threshold. That violates "latency must not regress" with no error. The defect is in the guard that the earlier fix round 322e26b introduced (the EWMA replacing STOP_MARGIN_PERCENT); that round did not handle rule changes. Fix: tie the costs to the rules they were measured under. When a round's or own evaluation's PersistentRules differs from the last-measured one, reset own_cost_us/shared_cost_us and stop sharing without counting a failed probe (do not bump stops), so the family re-probes once a plan-cached own cost under the new rules is known.
  • ⚠️ src/protocol/subscription/parameterized.rs:589 - Fix round 64f1a3c added the rule-change reset (Family::binds_recursion, lines 484-502). That fix only handles new evaluations; a round evaluation already running when the rules change can undo the reset. evaluate checks the shape once, before run_query. It then judges the finished round against whatever own_cost_us holds at that moment (lines 589-610).

Concrete sequence, family sharing under rules R1 with a ~200 ms round:

  1. A round at a pre-change snapshot starts evaluating.
  2. A rule change to R2 commits.
  3. Another view's own evaluation under R2 calls record_own. That resets sharing=false and own_cost_us=0.
  4. The old R1 round finishes. With own_cost_us=0, keeps_sharing returns true ("an unknown own cost keeps sharing"), so it sets sharing=true and puts an R1 cost into shared_cost_us.

The views then read R2 rounds with no measured own cost, and the guard can't stop them. Every round keeps sharing until a sample arrives after SAMPLE_EVERY=64 rounds. A probe spawned with a pre-change snapshot causes the same thing: its evaluate flips judged back to R1, resets the costs, and then shares on own=0.

This is the same failure the stale-costs-across-rule-change fix was meant to close (sharing under new rules without a comparison measured under them). It is still reachable, and it is now worse, because 0 means "always keep sharing" where the old stale cost at least gave a comparison. Recursion-bound shapes stay safe because the pre-check refuses them; the exposure is latency for non-recursive shapes. A round that fails under the old rules has a similar side effect: it calls stop_sharing, which counts a failure the reset had just cleared.

Fix at the judging step in evaluate: after run_query, skip smooth, keeps_sharing, stop_sharing and sharing.store(true) unless self.judged still holds the round's rules (Arc::ptr_eq). Also, don't let a round start sharing while own_cost_us == 0.

🔧 Fix applied.
1 info still open:

  • ℹ️ src/protocol/subscription/parameterized/tests.rs:486 - The new test a_round_from_before_a_rule_change_does_not_share_again only exercises the own &gt; 0 half of the fix in 6cf7687. The stale probe calls binds_recursion(before) at the start of evaluate, which sets judged back to the old rules, so current is true when the round finishes. Only own_cost_us == 0 keeps the family from sharing. The Arc::ptr_eq(judged.rules, rules) check after run_query (parameterized.rs:576-580) is never reached as the deciding guard. The test would still pass if that check were removed. Removing it would let this sequence happen undetected: a round at R1 is in flight, R2 is judged, an R2 own cost is recorded (>0), then the R1 round finishes, is compared against the R2 own cost, and re-arms sharing. To cover it, hold probe_gate so an R1 round evaluates past binds_recursion, then switch to R2 and record an R2 own cost before releasing the gate. Assert !family.shares() and shared_cost_us == 0.

🔧 Fix applied.
2 issues (1 warning, 1 info) still open:

  • ⚠️ src/protocol/subscription/parameterized.rs:672 - The judging guard only moves forward on the round path. The own-cost path still lets a cost measured under the old rules into the state for the new rules. This is a sibling left behind by fix rounds 64f1a3c (reset costs on rule change), 6cf7687 (discard stale round verdicts) and 57b4aba (forward-only judging). After 57b4aba, binds_recursion returns early for a snapshot older than the judged revision and does not reset anything. record_own (line 672) then calls smooth(&amp;self.own_cost_us, cost) with that snapshot's cost anyway. Concrete sequence: a family with 200 bindings on 4 permits, under rules R1. View A takes an R1 snapshot (reevaluate, line 811) and evaluates its own query with a cached plan, queued behind permits. A rule change to R2 commits. View B's own evaluation under R2 calls record_own, which judges R2 and resets own_cost_us to 0. A finishes and calls record_own(Some(R1 cost), R1 snapshot). The stale-revision branch skips the reset, and smooth sets own_cost_us to the R1 cost, because the previous value of 0 means it is taken verbatim. With stops==0, the next plan-cached R2 own evaluation returns true and probes at R2. The round is judged current, so it is compared against an average that is still mostly the R1 cost ((7·R1+R2)/8). If R1 own evaluations were expensive and R2 ones are cheap, the family starts sharing and keeps sharing, because once views read rounds the own cost is refreshed only every SAMPLE_EVERY=64 rounds at 1/8 weight. That is the exact latency regression the stale-costs fix was meant to prevent. Fix at the same boundary: in record_own, after binds_recursion, return false without calling smooth unless self.judged holds snapshot.persistent_rules() (Arc::ptr_eq), mirroring the current check in evaluate. Add a test: judge R2 (own reset), call record_own(Some(cost), before), and assert that own_cost_us stays 0 and the call returns false.
  • ℹ️ src/protocol/subscription/parameterized.rs:605 - In evaluate, current is read once after run_query (lines 595-599). If a rule change is judged (and the costs reset) between that read and sharing.store(true) / smooth(&amp;self.shared_cost_us, …), a round from the old rules can still re-arm sharing. The window is a few instructions wide, so this is very unlikely in practice. Re-checking the judged rules (Arc::ptr_eq) just before sharing.store(true) would narrow the window further, without adding a lock.

🔧 Fix applied.
3 issues (1 warning, 2 infos) still open:

  • ⚠️ src/protocol/subscription/parameterized.rs:672 - The judging guard only moves forward on the round path. The own-cost path still lets a cost measured under the old rules into the state for the new rules. This is a sibling left behind by fix rounds 64f1a3c (reset costs on rule change), 6cf7687 (discard stale round verdicts) and 57b4aba (forward-only judging). After 57b4aba, binds_recursion returns early for a snapshot older than the judged revision and does not reset anything. record_own (line 672) then calls smooth(&amp;self.own_cost_us, cost) with that snapshot's cost anyway. Concrete sequence: a family with 200 bindings on 4 permits, under rules R1. View A takes an R1 snapshot (reevaluate, line 811) and evaluates its own query with a cached plan, queued behind permits. A rule change to R2 commits. View B's own evaluation under R2 calls record_own, which judges R2 and resets own_cost_us to 0. A finishes and calls record_own(Some(R1 cost), R1 snapshot). The stale-revision branch skips the reset, and smooth sets own_cost_us to the R1 cost, because the previous value of 0 means it is taken verbatim. With stops==0, the next plan-cached R2 own evaluation returns true and probes at R2. The round is judged current, so it is compared against an average that is still mostly the R1 cost ((7·R1+R2)/8). If R1 own evaluations were expensive and R2 ones are cheap, the family starts sharing and keeps sharing, because once views read rounds the own cost is refreshed only every SAMPLE_EVERY=64 rounds at 1/8 weight. That is the exact latency regression the stale-costs fix was meant to prevent. Fix at the same boundary: in record_own, after binds_recursion, return false without calling smooth unless self.judged holds snapshot.persistent_rules() (Arc::ptr_eq), mirroring the current check in evaluate. Add a test: judge R2 (own reset), call record_own(Some(cost), before), and assert that own_cost_us stays 0 and the call returns false.
  • ℹ️ src/protocol/subscription/parameterized.rs:605 - In evaluate, current is read once after run_query (lines 595-599). If a rule change is judged (and the costs reset) between that read and sharing.store(true) / smooth(&amp;self.shared_cost_us, …), a round from the old rules can still re-arm sharing. The window is a few instructions wide, so this is very unlikely in practice. Re-checking the judged rules (Arc::ptr_eq) just before sharing.store(true) would narrow the window further, without adding a lock.
  • ℹ️ src/protocol/subscription/parameterized.rs:583 - Fix round 71ebbd2 missed one sibling write. That round gated every cost/sharing write on the judged rules, but evaluate still runs self.sharing.store(false) unconditionally whenever binds_recursion(&amp;snapshot) is true. For a snapshot older than the judged revision, binds_recursion returns the verdict under that old snapshot's rules (the judged.revision &gt; snapshot.revision branch), so a stale round changes sharing state under the current rules. Concrete sequence: rules R1 make a parameter bind a recursive relation. A rule change to R2 removes the recursion, and the family is judged and starts sharing under R2. A view that last validated under R1 then reads with an R1 snapshot, and no newer round exists yet. round_for creates an R1 round, evaluate sees binds_recursion=true under R1, and sharing is cleared under R2 without resetting costs or counting a stop. The next own evaluation then probes again. The result is wasted probe CPU and own evaluations, not a latency regression, but it breaks the stated invariant that snapshots older than the judged revision never update anything. Fix: only store false when self.judges(snapshot.persistent_rules()). For example, return the Err without writing when the snapshot's rules are not the judged ones.
✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 5 of 6 scenarios driven live against the product
Scenario Result Live Evidence
Voice-agent pack with one bound standing subscription per session (1–200 sessions): every session gets exactly its own deltas and write→delta latency/CPU do not regress vs base ✅ pass live paired-head-vs-base.md: head beats base at every size on loaded p50 and p99, uses far less CPU at 50+ sessions, 0 missing / 0 stray deltas on both arms
Few sessions (3 sessions, within compute permits): latency is no worse than base, since sharing must not kick in ✅ pass live paired-head-vs-base.md 3-session row: loaded p99 29.0 ms (head) vs 52.8 ms (base), 0/0 missing/stray; the unit test a_family_whose_views_fit_on_the_compute_permits_never_shares passed (targeted-tests.…
Operator sets INPUTLAYER_SUBSCRIPTIONS__SHARE_PARAMETERIZED=false: sharing turns off and deltas stay correct ✅ pass live killswitch-env.md: 50 sessions use 23.65 cores (vs 1.01 with sharing on), 0/0 missing/stray
Operator sets [subscriptions] share_parameterized = false in a config file: sharing turns off and deltas stay correct ✅ pass live killswitch-toml.md: 50 sessions use 23.69 cores, 0/0 missing/stray, with config.local.toml in the server's cwd
Adversarial: a misspelled key or non-boolean value under [subscriptions] stops startup instead of being silently ignored ✅ pass live config-validation.txt: 'unknown field share_parameterised' and 'expected a boolean' errors with exit 1; a valid false starts the HTTP listener
Mixed writes over WebSocket: each bound subscriber follows its own query, the row cap fails like the view's own query, and a single binding evaluates on its own ⏸️ untested no The prior payload did not establish a live result for this scenario. It was covered only by cargo test --test standing_query_tests parameterized (5 passed), which runs an in-process axum server from…
  • perf-gate sessions --server &lt;head release&gt; --compare &lt;base 0926f0f0 release&gt; --sessions 1,3,10,50,100,200 --load-secs 10 under systemd-run --user --scope -p MemoryMax=16G -p MemorySwapMax=0
  • perf-gate sessions --server &lt;head&gt; --server-env INPUTLAYER_SUBSCRIPTIONS__SHARE_PARAMETERIZED=false --sessions 10,50 (env kill switch)
  • perf-gate sessions --server &lt;wrapper writing config.local.toml [subscriptions] share_parameterized=false&gt; --sessions 50 (TOML kill switch)
  • Manual server start with [subscriptions] containing a misspelled key, a non-boolean value, and a valid false
  • cargo test --test standing_query_tests parameterized (5 WebSocket end-to-end tests against an in-process server on loopback)
  • cargo test --lib -- subscription::parameterized test_env_override_switches_parameterized_sharing_off (includes a_family_whose_views_fit_on_the_compute_permits_never_shares)

✅ No issues found.

  • Live validation: ✅ go - 4 of 5 scenarios driven live against the product
Scenario Result Live Evidence
40 session-bound subscriptions on one graph: each write produces a delta only for its session, and every view equals its one-off query ✅ pass live scenario-shared-40-sessions.log (190 oracle checks, quiet checks on the other 39 sessions, p50 21.6 ms / p99 23 ms)
Sharing switched off with INPUTLAYER_SUBSCRIPTIONS__SHARE_PARAMETERIZED=false: same exact results, so sharing is what cuts latency and CPU ✅ pass live scenario-sharing-off-40-sessions.log (p50 192 ms, server CPU 46.6 s vs 20.7 s with sharing on)
Adversarial: rule add, clause remove and rule delete race with writes while rounds are in flight; every view settles at its one-off query, with no duplicate insert, absent retract or out-of-order revi… ✅ pass live scenario-rule-changes-in-flight-run1..3.log, scenario-rule-changes-in-flight-sharing-off.log
Bound recursive subscriptions (?reach("nK", Y)) stay exact through edge add, remove and cut ✅ pass live scenario-recursive-bound.log (180 oracle checks)
Stale-rules guard: own costs and round verdicts measured under older rules never change sharing state ⏸️ untested no The prior payload did not establish a live result for this scenario. The cost and judged-rules state is internal, and the running server doesn't expose it (it has no metrics endpoint), so the guard's…
  • systemd-run --user --scope -p MemoryMax=16G -p MemorySwapMax=0 env CARGO_BUILD_JOBS=4 CARGO_PROFILE_DEV_DEBUG=0 CARGO_INCREMENTAL=0 cargo build --bin inputlayer-server
  • run.sh shared shared 40: real server (memory-capped, taskset 0-3), 40 WebSocket sessions each subscribed to ?view(&#34;sN&#34;, X, &#34;a&#34;); warm-up writes, 30 single-session writes with a delta check and quiet check on all other sessions, a move between sessions and a retraction, each compared against the one-off query
  • run.sh off shared 40 INPUTLAYER_SUBSCRIPTIONS__SHARE_PARAMETERIZED=false: the same scenario with sharing switched off by env
  • run.sh rules rules 40 x3: rule add, clause remove and full rule delete interleaved with writes and not awaited (rounds in flight); then drain every delta strictly, compare with the one-off query, then a targeted write and a quiet check
  • run.sh rulesoff rules 40 INPUTLAYER_SUBSCRIPTIONS__SHARE_PARAMETERIZED=false
  • run.sh rec recursive 40: 20 bound ?reach(&#34;nK&#34;, Y) subscriptions over a 40-node chain; edges added, removed and cut, each converged against the one-off query
  • cargo test --lib protocol::subscription::parameterized (23 tests, including an_own_cost_from_before_a_rule_change_is_dropped and a_round_from_before_a_rule_change_does_not_share_again), under the memory cap
  • cargo test --test standing_query_tests parameterized (5 end-to-end server tests), under the memory cap
✅ **Document** - passed

✅ No issues found.

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

✅ No issues found.

jsam added 15 commits October 4, 2026 18:38
…ations

Each standing-query re-evaluation recompiled its query with every
persistent rule it depends on (parse, specialization, SIP, Magic Sets, IR,
optimize): about 6.9 ms per evaluation on the voice-agent pack, repeated
for every view on every commit.

The engine now splits compilation from execution (IQLEngine::compile_program
and execute_compiled_profiled). Snapshots keep their parsed persistent rules
in a PersistentRules value that the next snapshot shares when the rules are
unchanged. That value also holds the plans compiled against those rules, keyed
by query program and optimizer passes. Standing queries (Handler::query_snapshot)
look their plan up there; a rule change publishes new PersistentRules with no
plans. Lookups are lock-free (ArcSwap, copy-on-write insert, bounded at 256
plans per rule set). Ad-hoc queries are unchanged.

RuleCatalog::all_rules now lists rules in a deterministic order, so snapshots
of the same rules have the same rule text (they differed by HashSet order).
Sharing the parsed rules also saves re-parsing them on every commit.
…riables with

The planner orders a rule's joins along a spanning tree of its join graph,
then builds them left-deep in that order. It used a post-order traversal,
which joins all of a node's children before the node itself. For a hub with
two spokes that share nothing, such as shipment(O, S), promised(O, P),
eta(S, D), it joined promised and eta first: a cross product of
|promised| x |eta| rows.

SIP generates exactly this shape for the voice-agent pack's late/on_time
rules, so their semijoin rule grew quadratically with the number of
sessions: 11 ms of a 37 ms evaluation at 100 sessions. The full speech query
took 83 ms at 200 sessions and now takes 35 ms.

The joins are now built in pre-order: each relation comes after its parent
in the tree, so it always shares a variable with what is already joined.
…in bound constants

With one session-bound subscription per session on one knowledge graph
(?speech("s-1", ...), speech_owner("s-1", "sp-1", 1), and the same for
s-2 ...), every commit re-evaluated every session's view. Each evaluation
cost about as much as the unbound query, so CPU grew with sessions x commits
x tenant size. The voice-agent pack saturated a 32-core host between 25 and
50 sessions.

Such views now form a family. A query's string constants in positive atoms,
and its integer constants that no other positive atom repeats, are lifted to
parameters (?speech(_L0, ...), speech_owner(_L0, _L1, _L2)). The family
evaluates the lifted query once per revision (a round, lock-free: ArcSwap
plus a OnceCell per round). Each view takes the rows of its own constants,
projected to its own columns, and diffs them against its previous result
as before. Rows are matched as the engine matches the constant: strings
exactly, integers numerically (int, timestamp, float within tolerance).
Subscribers, deltas, revisions, authorization and the shared-view state
machine are unchanged.

A view evaluates its own query when it starts and after every rule change,
so its errors stay its own. It also does when a round fails, is truncated,
or holds more rows for it than max_result_rows. A family with one binding
does not share. A family whose round costs more than twice a view's own
evaluation and saves less than half of all views' own evaluations stops
sharing, and probes again after 256 own evaluations.
http.rate_limit.subscription_share_parameterized (default true) turns it
off.

On the voice-agent pack at 1 receipt/s per session (red-team harness):
25 sessions use 0.51 instead of 17.4 cores; 100 sessions deliver at
p50/p95/p99 40/64/81 ms on 1.6 cores (baseline: 896/1151/1151 ms,
saturated).
perf-gate sessions (make bench-sessions, scripts/bench-sessions.sh)
rebuilds the hostile review's load harness as a repeatable benchmark. It
loads the article's Appendix A pack verbatim, seeds N sessions, and gives
each session a connection with its bound speech subscription. One executor
connection subscribes to work. Every session commits a result-neutral
receipt about once per second, while an adapter replaces random sessions'
ETAs and times the commit and that session's delta. Each size runs on a
fresh server. The run fails on a missing or stray delta or an error, and
optionally on a write-to-delta p99 budget. --baseline-rev measures a
baseline commit (cached with the gate's baselines) on the same sizes, and
--mode shared-view measures the review's client-side routing comparison.

The client gains API-key authentication: concurrent password logins from
one address are throttled by design. Server specs gain extra environment
for benchmarks, and running servers report their CPU time.
…onversion in sharing cost

A family's latest round kept its snapshot until the next round replaced it,
so an idle family pinned old data. The round now hands its snapshot to the
evaluation that takes it. The cost that decides whether sharing pays also
counts converting the rows and partitioning them, not only engine time: a
lifted query with a wide result is no longer judged cheaper than it is.
With more distinct standing queries than plan slots (for example more than
256 sessions evaluating their own queries), clearing the whole map on every
overflow left most lookups missing. Evicting one arbitrary plan keeps the
rest.
@jsam
jsam force-pushed the fm/il-session-scale-2 branch from 05d720f to 71ebbd2 Compare October 4, 2026 19:33
@jsam jsam changed the title feat(protocol): share evaluations across standing queries that differ only in bound constants, and cache their plans feat(protocol): share one evaluation among standing queries that differ only in bound constants, and cache their plans Oct 4, 2026
…a test fixture. It was a timing problem in the test, not a bug in the engine. `long_query_does_not_delay_deltas_on_its_connection` (`tests/ws_pipeline_tests.rs`) needs the cyclic triangle query on the complete bipartite graph to run for at least 500 ms (`LONG_QUERY`), so the test can show that deltas aren't held up behind it. On CI it finished in 390 ms. Locally, at the old size, it took about 620 ms, so the margin was always thin. This PR's join-planner change (pre-order joins in `src/join_planning/mod.rs`, which avoids cross products) likely made the query faster still. I didn't time it on the base commit to confirm. **What must hold:** the triangle query has to run well past every timing budget the tests measure against. Two places rely on it: `tests/ws_pipeline_tests.rs` (the 500 ms floor, the 300 ms idle-timeout test, and the disconnect test), and `tests/ws_cancel_tests.rs`, which uses the same graph and needs the query still running when its cancels arrive at 100–300 ms. **Fix:** in both files I raised the debug-build graph size `SIDE` from 10 to 14. The release-build value (20) is unchanged and I didn't time it; CI runs debug builds. Locally the query now takes 2.3 s instead of 0.62 s, so CI should see roughly 1.4 s, which is a wide margin. No engine code changed. **Verification:** I ran `cargo test --all-features --test ws_pipeline_tests --test ws_cancel_tests` under systemd-run with MemoryMax=16G, MemorySwapMax=0, CARGO_BUILD_JOBS=4, CARGO_PROFILE_DEV_DEBUG=0 and CARGO_INCREMENTAL=0. All 9 pipeline tests and all 6 cancel tests passed. I didn't re-run the rest of the workspace suite or any benchmarks
@jsam
jsam merged commit 29c4ce5 into main Oct 4, 2026
21 checks passed
@jsam
jsam deleted the fm/il-session-scale-2 branch October 4, 2026 19:45
jsam added a commit that referenced this pull request Oct 5, 2026
jsam added a commit that referenced this pull request Oct 5, 2026
jsam added a commit that referenced this pull request Oct 5, 2026
… suite, and publish engine baselines (#273)

* feat(perf-gate): run the gate on the dedicated benchmark host (scripts/perf-gate-remote.sh)

* feat(perf-gate): engine suite fixtures (rules, closure, deletes and updates, claims, .why, sessions, memory, recovery, async WAL) and a summary command

* feat(perf-gate): make perf-gate-remote, make bench-engine-remote, summary.md and --no-verdict

* docs(perf-gate): heavy perf runs go to the benchmark host; document the engine suite

* fix(perf-gate): record the host's CPU count, not the pinned gate's affinity; make perf-gate-remote.sh safe to edit while it runs

* fix(perf-gate): summary counts runs, not rounds

* fix(perf-gate): measure facts and graphs memory on separate servers, so freed memory cannot hide growth

* feat(perf-gate): perf-gate-remote.sh --wait-lock queues behind another benchmark on the host

* docs(perf-gate): engine baseline 2026-10-04 on sam-dev-benchmarks, A/A calibration records; summary skips empty tables

* feat(perf-gate): perf-gate-remote.sh --bench sessions runs #254's session-scale benchmark on the host

* feat(perf-gate): make bench-sessions-remote

* fix(perf-gate): perf-gate-remote.sh also waits out servers of a benchmark that skipped the lock

* fix(perf-gate): bench-sessions tells late deltas from missing ones (schema sessions/v2)

A probe whose delta took longer than the 10 s probe timeout counted as
missing, and its delta, when it came, as stray, so a saturated server
looked like one that loses deltas. Overdue probes now wait for their delta
(and 60 s more after the load stops); one that arrives counts as late with
its latency.

* docs(perf-gate): session-scale results on the benchmark host (100-1,000 sessions at 1 and 0.1 writes/s); late, not lost, deltas at saturation

* no-mistakes(review): Fail budget on late deltas; harden remote copy-back and runner

* no-mistakes(review): Untrack perf-gate build artifacts and ignore perf-gate/target

* no-mistakes(document): Document late deltas failing the sessions latency budget
jsam added a commit that referenced this pull request Oct 5, 2026
The three plan specs that #254's join-planner change broke
(33_meta/17_debug_join, 18_debug_recursive, 26_debug_complex) were
regenerated by #282 and pass on main. The new plans are correct: joins
follow body order, the identity Maps are gone, and 26's last rule no
longer builds a cross product (Join[L:[], R:[]]) before joining on PId.

They still ran only in the full suite, so a plan change could land
unnoticed. The fast gate's test job now runs them (make plan-snapshots)
with the dev binaries cargo test already built, so no second build.

- run_snapshot_tests.sh: --profile picks the binaries' cargo profile
  (default release); a filter that matches no spec fails instead of
  passing vacuously.
- ci.yml path filter includes the runner script.

Closes #290
jsam added a commit that referenced this pull request Oct 5, 2026
The three plan specs that #254's join-planner change broke
(33_meta/17_debug_join, 18_debug_recursive, 26_debug_complex) were
regenerated by #282 and pass on main. The new plans are correct: joins
follow body order, the identity Maps are gone, and 26's last rule no
longer builds a cross product (Join[L:[], R:[]]) before joining on PId.

They still ran only in the full suite, so a plan change could land
unnoticed. The fast gate's test job now runs them (make plan-snapshots)
with the dev binaries cargo test already built, so no second build.

- run_snapshot_tests.sh: --profile picks the binaries' cargo profile
  (default release); a filter that matches no spec fails instead of
  passing vacuously.
- ci.yml path filter includes the runner script.

Closes #290
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant