Repository navigation
fix(inputlayer-js): refuse NaN, infinities and out-of-range integers instead of emitting them as IQL variables - #251
Merged
Merged
Conversation
…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.
…son literals by column
…ge-integer tokens
This was referenced Oct 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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(inrelation.ts) now takes the column's type and throwsCompileErrorinstead of writing an unsafe literal. It refusesNaNand ±Infinity, which used to go out as bare tokens that IQL parses as variables, sokg.retract(Attempt, { order: NaN })matched and deleted every row. It also refusesbigints outside the signed 64-bit range,int/timestampnumbers pastNumber.MAX_SAFE_INTEGER, and vectors holding anything other than numbers. Whole-number floats are written asN.0(or in exponent form) so the engine doesn't read them back as integers.any()bindings, literals compared against typed columns,claim()andwhyNot().whyNotno longer has its own literal formatter that skipped these checks, andclaim()treats a returned row it can't encode as a non-match instead of throwing.intcolumns acceptbigint: it is added toFieldValue,ValueOf<'int'>and the comparison methods onColumnProxy.bigint. A newrowKeyhelper replacesJSON.stringifyfor row identity in subscriptions and in merging results across programs, and result sorting comparesnumberandbigintvalues together. The JS SDK guide documents the new int/float rules, and tests inguards,protocolandrelationcover the new behavior.Risk Assessment
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.
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)
Evidence: Live scenario transcript, target commit (fixed)
Evidence: Key before/after lines
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
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 afloatcolumn or a vector are now refused with CompileError. Before this change they were sent as digits or as1e+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_itememitrepr(float), so this breaks the Python parity the intent asks for in the float/vector case. Example:kg.insert(Scored, {order:1, tool:'t', score:1e20})used to work and now throws. The new test even assertscompileValue(1e300)throws. Fix options: use the column type where it is known (fact compilation hascolumnTypes) and refuse out-of-range values only forintcolumns, 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, inrows.some(r => r.every(v => 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 usesString(v)for non-strings. Sokg.whyNot(Scored, {order: 1, tool: 't', score: NaN})still sends a bareNaN/Infinitytoken, 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, inrows.some(r => r.every(v => 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 usesString(v)for non-strings. Sokg.whyNot(Scored, {order: 1, tool: 't', score: NaN})still sends a bareNaN/Infinitytoken, 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, sooursholds 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-throwingsame, 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:['id']})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('id').eq(2**60)becomes1.152921504606847e+18, where it used to be the exact1152921504606846976. 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). Sokg.query({where: Rel.col('id').eq(2**60)})silently returns nothing, andkg.delete(Rel, Rel.col('id').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 thirdcontext.sourceargument (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 noenginesfield. 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 declaringengines: {node: '>=22'}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 thirdcontext.sourceargument (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 noenginesfield. 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 declaringengines: {node: '>=22'}or noting the requirement.✅ **Test** - passed
✅ No issues found.
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-dirBuilt 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 servernpx vitest run tests/guards.test.ts tests/relation.test.ts tests/protocol.test.tsINPUTLAYER_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.