Skip to content

fix(inputlayer-js): refuse NaN, infinities and out-of-range integers instead of emitting them as IQL variables - #251

Merged
jsam merged 5 commits into
mainfrom
fm/fix-js-nan
Oct 4, 2026
Merged

jsam merged 5 commits into
mainfrom
fm/fix-js-nan

Conversation

@jsam

@jsam jsam commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

Intent

Keep fixing bugs and QCing the engine. (Context: the seven-role review of the SDK, messaging and value proposition verified these defects on main.) The referenced defect (finding F15, lab probe L12, a verified data loss): the JS SDK encoder emits non-finite numbers as bare NaN/Infinity tokens, which the IQL parser reads as variables, so kg.retract(Attempt, { order: Number("x") }) returned {"count":3} and emptied the relation (packages/inputlayer-js/src/relation.ts compileValue). The Python encoder (_literal.py) already refuses them with CompileError, as well as integers outside the engine's 64-bit range. The fix the review calls for: the JS encoder refuses non-finite and unsafe numbers like _literal.py, with Python parity, and a regression test that kg.retract(Attempt, {order: NaN}) raises CompileError and deletes nothing.

What Changed

  • compileValue (in relation.ts) now takes the column's type and throws CompileError instead of writing an unsafe literal. It refuses NaN and ±Infinity, which used to go out as bare tokens that IQL parses as variables, so kg.retract(Attempt, { order: NaN }) matched and deleted every row. It also refuses bigints outside the signed 64-bit range, int/timestamp numbers past Number.MAX_SAFE_INTEGER, and vectors holding anything other than numbers. Whole-number floats are written as N.0 (or in exponent form) so the engine doesn't read them back as integers.
  • Column types are now passed through every place that encodes values: inserts, bulk inserts, deletes, guarded programs, any() bindings, literals compared against typed columns, claim() and whyNot(). whyNot no longer has its own literal formatter that skipped these checks, and claim() treats a returned row it can't encode as a non-match instead of throwing. int columns accept bigint: it is added to FieldValue, ValueOf<'int'> and the comparison methods on ColumnProxy.
  • Server messages that contain integer tokens of 16 or more digits are now parsed so that integers past 2^53 decode exactly as bigint. A new rowKey helper replaces JSON.stringify for row identity in subscriptions and in merging results across programs, and result sorting compares number and bigint values together. The JS SDK guide documents the new int/float rules, and tests in guards, protocol and relation cover the new behavior.

Risk Assessment

⚠️ Medium: The NaN, Infinity and int-range fixes and the earlier-round fixes are correct and tested, but the new lossless decoding sends most float-bearing reply frames through a reviver that is about 8x slower, which should be tightened.

Testing

I built the engine server, ran it on a throwaway data directory, and ran one scenario script against it twice: once with the SDK built at the base commit and once at the target commit. On the base commit the reported data loss reproduces: a retract keyed on NaN deleted all 3 rows, and so did one keyed on Infinity. On the target commit every NaN and Infinity input is refused with CompileError and nothing is deleted. Finite floats still insert and read back unchanged. Whole numbers past 2^53 in int columns are refused with a hint to pass a BigInt. BigInts within the 64-bit range round-trip exactly, including through claim(), and BigInts outside it are refused. The targeted unit tests and the live guards integration test also passed. Both run transcripts were saved as evidence, and the server, temp files and build output were removed afterwards.

  • Live validation: ✅ go - 8 of 8 scenarios driven live against the product
Scenario Result Live Evidence
kg.retract(Attempt, {order: NaN}) raises CompileError and the 3 Attempt rows survive ✅ pass live after-4e17440c.txt S1 (base-c649d122.txt shows {count:3} and 0 rows left)
A retract keyed on Infinity or -Infinity is refused and deletes nothing ✅ pass live after-4e17440c.txt S1 (on base, Infinity emptied the relation)
NaN/Infinity are refused by delete(fact), delete(where score.eq(NaN)), insert, a query filter and whyNot ✅ pass live after-4e17440c.txt S2 (on base, the conditional delete emptied rows and the Infinity filter returned every row)
Finite floats (1e300, whole-number 3, 0.30000000000000004) still insert and read back ✅ pass live after-4e17440c.txt S3
A whole JS number past 2^53 in an int column (insert/retract/filter) raises CompileError asking for a BigInt; a BigInt outside i64 is refused ✅ pass live after-4e17440c.txt S4
A BigInt within i64 (2^53+1, i64 max) round-trips exactly through insert, query, filter and retract ✅ pass live after-4e17440c.txt S5
claim() keyed on a BigInt past 2^53 reports won:true for the holder and won:false for a rival ✅ pass live after-4e17440c.txt S6
Existing live guards/claims integration test still passes ✅ pass live INPUTLAYER_TEST_SERVER=ws://127.0.0.1:8097/ws npx vitest run tests/guards.integration.test.ts (12 passed)
Evidence: Live scenario transcript, base commit (bug reproduced)
== SDK build: base ==
S1 Attempt rows before: 3
  kg.retract(Attempt, {order: Number("x")}) -> returned {"count":3}
S1 Attempt rows after: 0
   (reseeded; rows now 3)
  kg.retract(Attempt, {order: Infinity}) -> returned {"count":3}
   rows after: 0
   (reseeded; rows now 3)
  kg.retract(Attempt, {order: -Infinity}) -> returned {"count":0}
   rows after: 3
   (reseeded; rows now 3)
S2 other paths with NaN
  kg.delete(Attempt, {order:1, tool:"a", score:NaN}) -> threw QueryError: Delete error: Cannot insert variable 'NaN' - use constants only
   rows after: 3
   (reseeded; rows now 3)
  kg.delete(Attempt, Attempt.col("score").eq(NaN)) -> returned {"count":1}
   rows after: 0
   (reseeded; rows now 3)
  kg.insert(Attempt, {order:9, tool:"z", score:NaN}) -> threw QueryError: Cannot insert variable 'NaN' - use constants only
   rows after: 3
  kg.query where score.eq(Infinity) -> returned [[1,"a",0.5],[2,"b",1.5],[3,"c",2.5]]
  kg.whyNot(Attempt, {order:1, tool:"a", score:NaN}) -> threw QueryError: Why-not error: Cannot parse value: NaN
S3 finite floats still accepted (incl. large/whole floats)
  insert scores 1e300, 3 (whole float), 0.30000000000000004 -> returned {"count":3}
  query those back -> returned [[10,"big",1e+300],[11,"whole",3],[12,"ulp",0.30000000000000004]]
S4 unsafe integers in int positions
  insert Big {id: 2**53+2 (number)} -> returned {"count":1}
  retract Big {id: 2**60 (number)} -> returned {"count":0}
  query where id.eq(2**60) (number) -> returned []
  insert Big {id: 2n**64n} (out of i64) -> threw TypeError: Cannot compile value of type bigint: 18446744073709551616
S5 BigInt in i64 range round-trips exactly
  insert Big {id: 9007199254740993n} and {id: 9223372036854775807n} -> threw TypeError: Cannot compile value of type bigint: 9007199254740993
  query Big rows -> returned [[9007199254740994,"num"]]
  query where id.eq(9007199254740993n) -> threw TypeError: Cannot compile value of type bigint: 9007199254740993
  retract Big {id: 9007199254740993n} -> threw TypeError: Cannot compile value of type bigint: 9007199254740993
  query Big rows after retract -> returned [[9007199254740994,"num"]]
S6 claim keyed on a BigInt past 2^53
  claim Claimed {id: 9007199254740993n, who:"me"} key id -> threw TypeError: Cannot compile value of type bigint: 9007199254740993
  claim again by "other" (should lose) -> threw TypeError: Cannot compile value of type bigint: 9007199254740993
Evidence: Live scenario transcript, target commit (fixed)
== SDK build: after ==
S1 Attempt rows before: 3
  kg.retract(Attempt, {order: Number("x")}) -> threw CompileError: IQL has no literal for NaN: it does not support infinity or NaN. use a finite number, or leave the value out
S1 Attempt rows after: 3
   (reseeded; rows now 3)
  kg.retract(Attempt, {order: Infinity}) -> threw CompileError: IQL has no literal for Infinity: it does not support infinity or NaN. use a finite number, or leave the value out
   rows after: 3
   (reseeded; rows now 3)
  kg.retract(Attempt, {order: -Infinity}) -> threw CompileError: IQL has no literal for -Infinity: it does not support infinity or NaN. use a finite number, or leave the value out
   rows after: 3
   (reseeded; rows now 3)
S2 other paths with NaN
  kg.delete(Attempt, {order:1, tool:"a", score:NaN}) -> threw CompileError: IQL has no literal for NaN: it does not support infinity or NaN. use a finite number, or leave the value out
   rows after: 3
   (reseeded; rows now 3)
  kg.delete(Attempt, Attempt.col("score").eq(NaN)) -> threw CompileError: IQL has no literal for NaN: it does not support infinity or NaN. use a finite number, or leave the value out
   rows after: 3
   (reseeded; rows now 3)
  kg.insert(Attempt, {order:9, tool:"z", score:NaN}) -> threw CompileError: IQL has no literal for NaN: it does not support infinity or NaN. use a finite number, or leave the value out
   rows after: 3
  kg.query where score.eq(Infinity) -> threw CompileError: IQL has no literal for Infinity: it does not support infinity or NaN. use a finite number, or leave the value out
  kg.whyNot(Attempt, {order:1, tool:"a", score:NaN}) -> threw CompileError: IQL has no literal for NaN: it does not support infinity or NaN. use a finite number, or leave the value out
S3 finite floats still accepted (incl. large/whole floats)
  insert scores 1e300, 3 (whole float), 0.30000000000000004 -> returned {"count":3}
  query those back -> returned [[10,"big",1e+300],[11,"whole",3],[12,"ulp",0.30000000000000004]]
S4 unsafe integers in int positions
  insert Big {id: 2**53+2 (number)} -> threw CompileError: Integer 9007199254740994 is past Number.MAX_SAFE_INTEGER, so it may already have lost digits. pass the integer as a BigInt
  retract Big {id: 2**60 (number)} -> threw CompileError: Integer 1152921504606847000 is past Number.MAX_SAFE_INTEGER, so it may already have lost digits. pass the integer as a BigInt
  query where id.eq(2**60) (number) -> threw CompileError: Integer 1152921504606847000 is past Number.MAX_SAFE_INTEGER, so it may already have lost digits. pass the integer as a BigInt
  insert Big {id: 2n**64n} (out of i64) -> threw CompileError: Integer 18446744073709551616 does not fit the engine's 64-bit integers. keep integers within [-9223372036854775808, 9223372036854775807], or store it as a string
S5 BigInt in i64 range round-trips exactly
  insert Big {id: 9007199254740993n} and {id: 9223372036854775807n} -> returned {"count":2}
  query Big rows -> returned [["9007199254740993n","p53+1"],["9223372036854775807n","max"]]
  query where id.eq(9007199254740993n) -> returned [["9007199254740993n","p53+1"]]
  retract Big {id: 9007199254740993n} -> returned {"count":1}
  query Big rows after retract -> returned [["9223372036854775807n","max"]]
S6 claim keyed on a BigInt past 2^53
  claim Claimed {id: 9007199254740993n, who:"me"} key id -> returned {"won":true,"holder":{"id":"9007199254740993n","who":"me"}}
  claim again by "other" (should lose) -> returned {"won":false,"holder":{"id":"9007199254740993n","who":"me"}}
Evidence: Key before/after lines
base: kg.retract(Attempt, {order: Number("x")}) -> returned {"count":3}; rows after: 0
after: kg.retract(Attempt, {order: Number("x")}) -> threw CompileError: IQL has no literal for NaN ...; rows after: 3

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ⚠️ packages/inputlayer-js/src/relation.ts:184 - The new 64-bit range check also rejects valid floats. JS cannot tell an int from a float whose value happens to be whole, and every double at or above 2^63 in magnitude is a whole number. So values like 1e19, 1e20, 1.5e19 and 1e300 in a float column or a vector are now refused with CompileError. Before this change they were sent as digits or as 1e+21, which the engine parses as f64 (src/parser/mod.rs:361-369: it tries i64 and falls back to f64), so they round-tripped correctly. Python keeps accepting them, because _encode_float/_encode_vector_item emit repr(float), so this breaks the Python parity the intent asks for in the float/vector case. Example: kg.insert(Scored, {order:1, tool:&#39;t&#39;, score:1e20}) used to work and now throws. The new test even asserts compileValue(1e300) throws. Fix options: use the column type where it is known (fact compilation has columnTypes) and refuse out-of-range values only for int columns, or emit an integral value past the range as a float literal. Vector items should always be encoded as floats, as in Python. This is ask-user because choosing the type rule for type-blind compileValue callers is a policy decision.
  • ⚠️ packages/inputlayer-js/src/knowledge-graph.ts:407 - claim() runs compileValue on rows the engine sends back, in rows.some(r =&gt; r.every(v =&gt; compileValue(v) === ours[i])), after the claim program has already committed. compileValue can now throw on those values. An i64 such as 9223372036854775807 is JSON-decoded to the number 2^63, and a holder row can hold a float ≥2^63 (for example one written by the Python SDK). Either one makes claim() throw CompileError, even though the write went through, so the caller cannot tell whether it won. Before this change the comparison just failed to match. Fix: compare engine values without throwing, for example by wrapping the call in a try/catch that counts as no match, or with a non-throwing comparison helper.
  • ⚠️ packages/inputlayer-js/src/knowledge-graph.ts:804 - whyNot() has its own literal encoder that bypasses compileValue: it uses String(v) for non-strings. So kg.whyNot(Scored, {order: 1, tool: &#39;t&#39;, score: NaN}) still sends a bare NaN/Infinity token, which the parser reads as a variable. The query then explains a pattern instead of the fact, with no error. The new test says NaN is refused on 'every write and query path', but this read path is missed. It cannot lose data, but it returns a wrong result silently. Fix: encode each value with compileValue (this also brings in the full string escaping).
  • ℹ️ packages/inputlayer-js/src/relation.ts:183 - The intent asks the JS encoder to refuse 'non-finite and unsafe numbers like _literal.py'. The change reads 'unsafe' as 'outside signed 64-bit', which matches Python. But integers above Number.MAX_SAFE_INTEGER (2^53) and below 2^63 are still accepted, even though they may already have been rounded before reaching the SDK. For example, a retract keyed on 9007199254740993 actually targets ...992. Please confirm that the i64 range is the intended meaning of 'unsafe', not Number.isSafeInteger.

🔧 Fix applied.
4 warnings still open:

  • ⚠️ packages/inputlayer-js/src/knowledge-graph.ts:407 - claim() runs compileValue on rows the engine sends back, in rows.some(r =&gt; r.every(v =&gt; compileValue(v) === ours[i])), after the claim program has already committed. compileValue can now throw on those values. An i64 such as 9223372036854775807 is JSON-decoded to the number 2^63, and a holder row can hold a float ≥2^63 (for example one written by the Python SDK). Either one makes claim() throw CompileError, even though the write went through, so the caller cannot tell whether it won. Before this change the comparison just failed to match. Fix: compare engine values without throwing, for example by wrapping the call in a try/catch that counts as no match, or with a non-throwing comparison helper.
  • ⚠️ packages/inputlayer-js/src/knowledge-graph.ts:804 - whyNot() has its own literal encoder that bypasses compileValue: it uses String(v) for non-strings. So kg.whyNot(Scored, {order: 1, tool: &#39;t&#39;, score: NaN}) still sends a bare NaN/Infinity token, which the parser reads as a variable. The query then explains a pattern instead of the fact, with no error. The new test says NaN is refused on 'every write and query path', but this read path is missed. It cannot lose data, but it returns a wrong result silently. Fix: encode each value with compileValue (this also brings in the full string escaping).
  • ⚠️ packages/inputlayer-js/src/knowledge-graph.ts:408 - The round-1 fix for F2/F4 made claim() report a false loss when the row holds a BigInt past 2^53. Round 1 started accepting BigInt in int columns, so ours holds exact digits, for example "9007199254740993". The engine reply is decoded with plain JSON.parse (protocol.ts:374), which rounds that i64 to the number 9007199254740992. In the new non-throwing same, compileValue(9007199254740992, 'int') then throws because the value is past MAX_SAFE_INTEGER. The catch turns that into false. Concrete case: kg.claim(Rel, {id: 9007199254740993n, ...}, {key:[&#39;id&#39;]}) commits and the engine returns our own row, yet claim() returns {won:false, holder:<our row with id rounded>}. The caller is told it lost a claim it actually holds, and nothing errors. Before this change BigInt was refused outright, so this wrong-result path is new. No other compare site uses engine rows. Remedy options: refuse a BigInt outside the safe range up front in claim(), or decode engine integers losslessly. The second extends the protocol layer, so the remedy choice needs authorization.
  • ⚠️ packages/inputlayer-js/src/compiler.ts:154 - The round-1 fix (F1/F4) left the comparison-literal path behind and changed what it sends. compileExpr compiles a Literal with compileValue(expr.value) and no column type. A whole number past 2^53 in an int-column filter now goes out as a float literal: Rel.col(&#39;id&#39;).eq(2**60) becomes 1.152921504606847e+18, where it used to be the exact 1152921504606846976. The user decision says such a number in an int position must be refused with a CompileError asking for a BigInt. Here it is neither refused nor sent as an int. The engine's equality does not treat Int64 and Float64 as equal (src/provenance/unification.rs:225 values_equal). So kg.query({where: Rel.col(&#39;id&#39;).eq(2**60)}) silently returns nothing, and kg.delete(Rel, Rel.col(&#39;id&#39;).eq(2**60)) silently deletes nothing, where both used to match. Other sites that compile a literal without its type: compiler.ts:186 (aggregate params), and compiler.ts:491/501, which compare an untyped encoding against a typed one. Remedy: resolve the column's type for Column-vs-Literal comparisons and pass it to compileValue, or define a policy for when the type is unknown (for example, keep integral values within i64 as digits). The user decision only covered cases where the type is known, so the unknown-type policy needs confirmation.

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

  • ⚠️ packages/inputlayer-js/src/protocol.ts:387 - Round 2 (the R2-1 fix) added lossless integer decoding. It is meant to switch to the slow reviver path only when a frame holds a large integer, but the gate /\d{16}/.test(data) also matches the 16–17 significant digits that serde/ryu writes for ordinary floats (e.g. 0.30000000000000004, or any random score). So almost every result, subscription snapshot or delta frame with a float column now goes through JSON.parse with a reviver. I measured a 50k-row [int, string, float] frame on Node 22: 14 ms plain versus 112 ms with the reviver, about 8x slower. This sits on the hot path for every query and subscription delta (see issue Pipelined standing-query evaluations: bring 100-session write→delta p99 under 100 ms #238 on the p99 write→delta latency target). Fix: tighten the gate so it matches only a standalone integer token of 16 or more digits, i.e. not inside a fraction or exponent. One option: /(?:^|[^\d.eE+-])-?\d{16,}(?![\d.eE])/. A false positive inside a string literal only costs speed, not correctness.
  • ℹ️ packages/inputlayer-js/src/protocol.ts:375 - The lossless decoding relies on the reviver's third context.source argument (JSON.parse source text access). Node 22 (used in CI) has it. On runtimes without it the reviver gets no context, so it silently falls back to lossy numbers. The package declares no engines field. On those runtimes the R2-1 false-loss path in claim() (a BigInt key past 2^53 reported as {won:false}) can still happen. Consider declaring engines: {node: &#39;&gt;=22&#39;} or noting the requirement.

🔧 Fix applied.
1 info still open:

  • ℹ️ packages/inputlayer-js/src/protocol.ts:375 - The lossless decoding relies on the reviver's third context.source argument (JSON.parse source text access). Node 22 (used in CI) has it. On runtimes without it the reviver gets no context, so it silently falls back to lossy numbers. The package declares no engines field. On those runtimes the R2-1 false-loss path in claim() (a BigInt key past 2^53 reported as {won:false}) can still happen. Consider declaring engines: {node: &#39;&gt;=22&#39;} or noting the requirement.
✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 8 of 8 scenarios driven live against the product
Scenario Result Live Evidence
kg.retract(Attempt, {order: NaN}) raises CompileError and the 3 Attempt rows survive ✅ pass live after-4e17440c.txt S1 (base-c649d122.txt shows {count:3} and 0 rows left)
A retract keyed on Infinity or -Infinity is refused and deletes nothing ✅ pass live after-4e17440c.txt S1 (on base, Infinity emptied the relation)
NaN/Infinity are refused by delete(fact), delete(where score.eq(NaN)), insert, a query filter and whyNot ✅ pass live after-4e17440c.txt S2 (on base, the conditional delete emptied rows and the Infinity filter returned every row)
Finite floats (1e300, whole-number 3, 0.30000000000000004) still insert and read back ✅ pass live after-4e17440c.txt S3
A whole JS number past 2^53 in an int column (insert/retract/filter) raises CompileError asking for a BigInt; a BigInt outside i64 is refused ✅ pass live after-4e17440c.txt S4
A BigInt within i64 (2^53+1, i64 max) round-trips exactly through insert, query, filter and retract ✅ pass live after-4e17440c.txt S5
claim() keyed on a BigInt past 2^53 reports won:true for the holder and won:false for a rival ✅ pass live after-4e17440c.txt S6
Existing live guards/claims integration test still passes ✅ pass live INPUTLAYER_TEST_SERVER=ws://127.0.0.1:8097/ws npx vitest run tests/guards.integration.test.ts (12 passed)
  • cargo build --release --bin inputlayer-server, then started a disposable server on 127.0.0.1:8097 with a temp --data-dir
  • Built the SDK at both the base commit (c649d122) and the target commit (4e17440c) with tsup, and ran the same live scenario script (node scenarios.mjs base|after) against the server
  • npx vitest run tests/guards.test.ts tests/relation.test.ts tests/protocol.test.ts
  • INPUTLAYER_TEST_SERVER=ws://127.0.0.1:8097/ws npx vitest run tests/guards.integration.test.ts
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

jsam added 5 commits October 4, 2026 14:00
…ers in compileValue

NaN and Infinity were written as bare tokens, which the IQL parser reads as
variables, so kg.retract(Attempt, { order: NaN }) deleted every row.
compileValue now raises CompileError for non-finite numbers (including
invalid Dates and Timestamps) and for integers outside the signed 64-bit
range, matching the Python encoder, and writes integers with exact digits.
@jsam
jsam merged commit c08f580 into main Oct 4, 2026
20 checks passed
@jsam
jsam deleted the fm/fix-js-nan branch October 4, 2026 14:22
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