Skip to content

fix(inputlayer-py): timestamps, meta commands, in_(), negation and literals the engine accepts - #239

Merged
jsam merged 3 commits into
mainfrom
fm/sdk-p1-c2
Oct 4, 2026
Merged

jsam merged 3 commits into
mainfrom
fm/sdk-p1-c2

Conversation

@jsam

@jsam jsam commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Intent

So fix the SDKs by improving them. (Context: the captain asked to rework the SDKs so agents and agentic loops use InputLayer without writing Datalog, and to fix the SDK release blockers by improving the SDKs rather than patching. The agreed design, signed off by an independent challenger after three rounds, is ~/firstmate/data/sdk-design/report.md; this item is its phase-1 PR P1-C2 P1-C2 (Py). Part 1 (P1-C1) merged as #235.)

The referenced item, P1-C2 of the agreed design (Python SDK, compile path v2 part 2), is: R-TYPE, R-META (shared command fixture, .rule def parsing), R-IN, R-NEG check and il_const binding, and the R-LIT encoder with the fuzz round-trip, each pinned by unit tests on compiled text and live tests against a real engine that run in CI. The design's rules:

  • R-TYPE: datetime/Date/Timestamp columns declare int and carry Unix milliseconds; values are converted both ways.
  • R-META: meta commands come from one shared table (.session, .rule def, .rule drop, .subscribe, .unsubscribe, .why, .why_not), the same JSON fixture in both SDKs (packages/conformance/meta-commands.json, already used by the JS SDK), so a wrong spelling (.session list) cannot exist in one SDK only; the .rule def reply is parsed (rule_definition() returned an empty list for a live rule).
  • R-IN: col.in_(Other.col) emits a positive atom other(_, V, _) with V unified to col; not-in emits the negated atom under R-NEG. Never rel(..., X, ...).
  • R-NEG: ~ on an atom emits !r(...). A negated atom must share a variable with a positive atom (otherwise a CompileError before anything is sent, because the engine accepts such a rule and then fails every query of it). It never adds a filter to a positive atom. A negated atom linked to the body only by a constant binds the constant through an SDK-owned typed constant relation: on the read path a program-local session fact in the same program (il_const_s("x") then ..., il_const_s(K), K = "x", !r(K)); for views persistent il_const_<type> rows written in the same program as the clauses (never deleted, rows may be shared). In a view each clause body is ordered positive atoms, then negated atoms, then comparisons and equalities. The write-path binding (staged il_txn_const rows) belongs to a later item (P1-I), so a conditional delete needing it is refused.
  • R-LIT: every literal (string with escapes, newlines and unicode; int; float, with NaN and Infinity refused with a CompileError because IQL has no literal for them; bool; vector) goes through one audited encoder per SDK, the only place program text is built from values. A fuzz test in CI round-trips random values through the engine (encode, insert, read back, compare). Model-supplied values never reach program text any other way.
    Other phase-1 items and phases 2-4 are out of scope.

What Changed

  • Datetime columns and meta commands: datetime and Timestamp columns are now declared as int and stored as Unix milliseconds. Values are converted on the way in and in result columns on the way out. Meta commands (.session, .rule def, .rule drop, .rule remove, and so on) now come from a new _meta table that renders the shared packages/conformance/meta-commands.json fixture, so the Python and JS SDKs send the same text. The SDK also parses the replies:
    • rule_definition() returns a live rule's clauses.
    • Session.list_rules() returns one entry per clause.
    • Session.drop_rule(name, index=) works out the clause's position among all session rules.
  • Compiler:
    • col.in_(Other.col) emits a positive atom that unifies with col. A negated in_ emits a negated atom.
    • ~ on an atom emits !r(...). If a negated atom shares no variable with a positive atom, compiling raises a CompileError.
    • When a negated atom is linked to the body only by a constant, that constant is bound through an il_const_* relation: a session fact in queries and session rules, persistent rows in views.
    • View clause bodies are ordered: positive atoms first, then negated atoms, then comparisons.
    • A conditional delete that would need this constant binding is refused.
  • Literal encoder and tests: A new _literal encoder is now the only place values become program text. It covers strings (escapes, newlines, unicode), ints, floats, bools and vectors, and raises a CompileError for NaN and Infinity. The compiler, vector-search radius and limit values, and the LangChain/LangGraph params all use it. New unit tests check the compiled text (test_compile_rules.py, test_meta.py). A new live suite, compile_rules_live.py, runs in make python-sdk-live and includes a round-trip fuzz of random literals through a real engine.

Risk Assessment

✅ Low: The fix-round commits correctly close the round-1 findings: datetime conversion now happens once in QueryPlan for whole-relation, projected, aggregate min/max and .why rows, including Optional[datetime], while Timestamp stays an int. Radius and limit/offset now go through the encoder, and the fixture text is updated. All of this is pinned by unit tests and by live tests that CI runs through make python-sdk-live.

Testing

I built and ran the real inputlayer-server on an isolated port and data directory. Against it I ran the new compile_rules_live suite (the shared meta-command fixture, rule_definition, datetime, in_/not-in, ground-negation constants in queries and views, and the literal fuzz), the docs-guide live suite, four extra fuzz seeds, and a custom adversarial driver that records the exact program text sent. Everything passed: 48/48 live tests, 27/27 driver checks, and all four seeds. I tore down the server and data directory afterwards. One thing outside this change's scope: describe() sends .rel &lt;name&gt; and returns data rows instead of the schema. This code was not touched by the diff. There is no UI surface, so the evidence is CLI transcripts.

  • Live validation: ✅ go - 10 of 10 scenarios driven live against the product
Scenario Result Live Evidence
R-META: every command in the shared conformance fixture runs on the engine through the SDK's table, and the rejected spellings (e.g. .session list) fail ✅ pass live compile_rules_live.py::test_every_fixture_command_runs_and_rejected_spellings_fail PASSED; drive_sdk_transcript.txt '.session list rejected by engine'
R-META: rule_definition() of a live rule returns its clauses (not []), and list/drop by clause work for persistent and session rules ✅ pass live test_rule_definition_reads_the_clauses, test_session_rules_list_and_drop_by_clause PASSED; driver: .rule def lead -> ['lead(Name) <- emp(Id, Name, ), boss(, Id)']
R-TYPE/F1: datetime columns are stored as Unix ms and read back as UTC datetimes in whole-relation rows, projected rows, to_dicts, iteration and min() aggregates, while Timestamp columns stay ints ✅ pass live test_datetime_columns_define_insert_and_read_back PASSED; driver: insert sent ("a", 1000, 1791117000250, 5), projected.rows [[datetime(...), 1791117000250]], condition At &gt; 1767225600000
R-IN: col.in_(Other.col) emits a positive atom with an anonymous/unified variable, and not-in emits the negated atom, both in queries, rules and a conditional delete ✅ pass live test_in_and_not_in_queries, test_in_in_a_rule_and_a_conditional_delete PASSED; driver sent ?emp(Id, Name, Dept), boss(_, Id) / !boss(_, Id)
R-NEG adversarial: a negation sharing no variable with a positive atom is refused with a CompileError before anything is sent ✅ pass live test_negation_sharing_no_variable_is_refused_before_sending PASSED; driver spy shows no request sent
R-NEG: a ground negation binds its constant through il_const_s, as a request-local session fact for reads (nothing left behind) and as persistent rows for persistent and session views, which react to… ✅ pass live test_ground_negation_binds_a_constant_for_the_request_only, test_view_with_ground_negation, test_session_view_with_ground_negation, test_negated_comparison PASSED
R-LIT fuzz: random strings (escapes, newlines, unicode), i64 extremes, finite floats incl. -0.0, bools and vectors round-trip through the engine and also match as condition constants ✅ pass live test_literals_round_trip_through_the_engine PASSED (random seed + seeds 1, 42, 31337, 2024 in fuzz_seeds.txt)
R-LIT adversarial: injection-shaped strings are stored verbatim without creating extra facts, and NaN/±Infinity/lone surrogates are refused with a CompileError before sending ✅ pass live test_unrepresentable_values_are_refused_before_sending PASSED; driver: no row 99, verbatim read-back, nan/inf/-inf refused before sending
F2 adversarial: vector_search radius and query limit/offset go through the encoder (nan/inf radius and negative or non-int limit/offset are refused before sending; an injection-shaped radius becomes o… ✅ pass live drive_sdk_transcript.txt: Dist &lt;= 0.5 finds doc 1; Dist &lt;= &#34;0.5), evil(X&#34; -> []; nan/inf/limit=-1/offset=-5 refused with nothing sent; limit(2, 1) -> [[2],[3]]
No regression in the documented Python SDK guide queries ✅ pass live tests/docs_queries_live.py: all tests PASSED in live_pytest.txt
Evidence: Adversarial live driver transcript (program text sent + PASS/FAIL per check)

== R-IN / R-NEG ==
sent: ?emp(Id, Name, Dept), boss(_, Id)
rows: [['Alice']]
[PASS] in_ returns only bosses 
[PASS] in_ emits a positive boss atom, no rel(..., X, ...) filter 
sent: ?emp(Id, Name, Dept), !boss(_, Id)
rows: [['Bob'], ['Cara']]
[PASS] ~in_ returns non-bosses 
[PASS] ~in_ emits a negated atom !boss(...) 
[PASS] unlinked negation refused with CompileError before sending (The negated atom !boss(_, EmpId) shares no variable with a positive atom (test a column of a relation the query or rule joins))

== R-LIT injection attempts ==
sent: +emp[(10, "x\"), emp(99, \"pwn\", \"x", "sec"), (11, "line1\nline2\t\"q\" \\ é 漢 🚀 // /* */ <- ?", "sec")]
[PASS] injection-shaped string stored verbatim 'x"), emp(99, "pwn", "x'
[PASS] escapes/newlines/unicode stored verbatim 'line1\nline2\t"q" \\ é 漢 🚀 // /* */ <- ?'
[PASS] no injected row 99 exists 
[PASS] injection string works as condition constant 
[PASS] float nan refused in condition before sending (IQL has no literal for nan: it does not support infinity or NaN (use a finite float, or leave the value out))
[PASS] float inf refused in condition before sending (IQL has no literal for inf: it does not support infinity or NaN (use a finite float, or leave the value out))
[PASS] float -inf refused in condition before sending (IQL has no literal for -inf: it does not support infinity or NaN (use a finite float, or leave the value out))

== vector_search radius / limit (F2) ==
sent: ?doc(Id, Title, Embedding), Dist = cosine(Embedding, [1.0, 0.0, 0.0]), Dist <= 0.5
rows: [[1, 'a', [1.0, 0.0, 0.0], 0.0]]
[PASS] radius=0.5 finds doc 1 only 
sent: ?doc(Id, Title, Embedding), Dist = cosine(Embedding, [1.0, 0.0, 0.0]), Dist <= "0.5), evil(X"
rows: []
[PASS] injection-shaped radius string is encoded as one quoted literal (no evil atom) 
[PASS] radius=nan refused before sending (IQL has no literal for nan: it does not support infinity or NaN (use a finite float, or leave the value out))
[PASS] radius=inf refused before sending (IQL has no literal for inf: it does not support infinity or NaN (use a finite float, or leave the value out))
[PASS] {'limit': -1} refused before sending (limit must be a non-negative int, got -1 (pass limit as an int of 0 or more))
[PASS] {'limit': '1; drop'} refused before sending (limit must be a non-negative int, got '1; drop' (pass limit as an int of 0 or more))
[PASS] {'offset': -5} refused before sending (offset must be a non-negative int, got -5 (pass offset as an int of 0 or more))
[PASS] {'limit': True} refused before sending (limit must be a non-negative int, got True (pass limit as an int of 0 or more))
sent: il_q(Id) <- emp(Id, Name, Dept)
?il_q(Id:asc), limit(2, 1)
[PASS] limit=2 offset=1 pages [[2], [3]]

== R-TYPE datetime (F1) ==
sent: +event[("a", 1000, 1791117000250, 5), ("b", 1791117000250, 1000, 1791117000250)]
whole.rows: [['a', datetime.datetime(1970, 1, 1, 0, 0, 1, tzinfo=datetime.timezone.utc), datetime.datetime(2026, 10, 4, 12, 30, 0, 250000, tzinfo=datetime.timezone.utc), 5]]
[PASS] whole-relation rows convert datetime, keep Timestamp int 
[PASS] typed iteration gives datetime incl second datetime col Event(label='a', at=datetime.datetime(1970, 1, 1, 0, 0, 1, tzinfo=datetime.timezone.utc), ended=datetime.datetime(2026, 10, 4, 12, 30, 0, 250000, tzinfo=datetime.timezone.utc), seen=5)
projected.rows: [[datetime.datetime(1970, 1, 1, 0, 0, 1, tzinfo=datetime.timezone.utc), 1791117000250]] to_dicts: [{'ended': datetime.datetime(1970, 1, 1, 0, 0, 1, tzinfo=datetime.timezone.utc), 'seen': 1791117000250}]
[PASS] projected datetime column converted 
sent: ?event(Label, At, Ended, Seen), At > 1767225600000
[PASS] datetime condition compares ms 

== R-META ==
sent: .rule def lead
rule_definition: ['lead(Name) <- emp(Id, Name, _), boss(_, Id)']
[PASS] rule_definition returns the live clause 
.session -> [['No session data defined.']]
[PASS] .session list rejected by engine (Program has 1 parse error(s)
  query: .session list)

FAILURES: 0
Evidence: Adversarial live driver script
"""Adversarial live driver for P1-C2 (Python SDK) against a real inputlayer-server.

Wraps the KG connection's execute() as a spy (it still calls the real engine)
so the transcript shows the exact program text the SDK sent.
"""
import asyncio, contextlib, math, os, sys
from datetime import datetime, timezone
from typing import ClassVar, Optional

from inputlayer import Derived, From, InputLayer, Relation, Timestamp, Vector
from inputlayer.exceptions import CompileError, QueryError

URL = os.environ["INPUTLAYER_TEST_SERVER"]
SENT: list[str] = []
FAIL = 0

def check(name, ok, detail=""):
    global FAIL
    print(f"[{'PASS' if ok else 'FAIL'}] {name} {detail}")
    FAIL += 0 if ok else 1

class Emp(Relation):
    id: int
    name: str
    dept: str

class Boss(Relation):
    team: str
    emp_id: int

class Event(Relation):
    label: str
    at: datetime
    ended: datetime
    seen: Timestamp

class Doc(Relation):
    id: int
    title: str
    embedding: Vector[3]

async def main():
    il = InputLayer(URL, username="admin", password="admin")
    await il.connect()
    with contextlib.suppress(Exception):
        await il.drop_knowledge_graph("nm_drive_py")
    kg = il.knowledge_graph("nm_drive_py")
    await kg.define(Emp, Boss, Event, Doc)
    real = kg._conn.execute
    async def spy(iql, **kw):
        SENT.append(iql)
        return await real(iql, **kw)
    kg._conn.execute = spy

    await kg.insert([Emp(id=1, name="Alice", dept="eng"), Emp(id=2, name="Bob", dept="hr"),
                     Emp(id=3, name="Cara", dept="eng")])
    await kg.insert([Boss(team="core", emp_id=1)])

    print("\n== R-IN / R-NEG ==")
    SENT.clear()
    r = await kg.query(Emp.name, where=lambda e: e.id.in_(Boss.emp_id))
    print("sent:", SENT[-1]); print("rows:", r.rows)
    check("in_ returns only bosses", r.rows == [["Alice"]])
    check("in_ emits a positive boss atom, no rel(..., X, ...) filter", "boss(" in SENT[-1] and "!boss" not in SENT[-1])
    SENT.clear()
    r = await kg.query(Emp.name, where=lambda e: ~e.id.in_(Boss.emp_id))
    print("sent:", SENT[-1]); print("rows:", sorted(r.rows))
    check("~in_ returns non-bosses", sorted(r.rows) == [["Bob"], ["Cara"]])
    check("~in_ emits a negated atom !boss(...)", "!boss(" in SENT[-1])
    SENT.clear()
    try:
        await kg.query(Emp.name, where=lambda e: ~Boss.emp_id.in_(Boss.emp_id))
        check("unlinked negation refused", False, "no error")
    except CompileError as exc:
        check("unlinked negation refused with CompileError before sending", SENT == [], f"({exc})")

    print("\n== R-LIT injection attempts ==")
    evil = 'x"), emp(99, "pwn", "x'
    weird = 'line1\nline2\t"q" \\ é 漢 🚀 // /* */ <- ?'
    await kg.insert([Emp(id=10, name=evil, dept="sec"), Emp(id=11, name=weird, dept="sec")])
    print("sent:", SENT[-1])
    r = await kg.query(Emp.id, Emp.name, where=lambda e: e.dept == "sec")
    got = {row[0]: row[1] for row in r.rows}
    check("injection-shaped string stored verbatim", got.get(10) == evil, repr(got.get(10)))
    check("escapes/newlines/unicode stored verbatim", got.get(11) == weird, repr(got.get(11)))
    r = await kg.query(Emp.id, where=lambda e: e.id == 99)
    check("no injected row 99 exists", r.rows == [])
    r = await kg.query(Emp.id, where=lambda e: e.name == evil)
    check("injection string works as condition constant", r.rows == [[10]])
    for bad in (math.nan, math.inf, -math.inf):
        SENT.clear()
        try:
            await kg.query(Emp.name, where=lambda e, b=bad: e.id == b)
            check(f"float {bad} refused in condition", False)
        except CompileError as exc:
            check(f"float {bad} refused in condition before sending", SENT == [], f"({exc})")

    print("\n== vector_search radius / limit (F2) ==")
    await kg.insert([Doc(id=1, title="a", embedding=[1.0, 0.0, 0.0]), Doc(id=2, title="b", embedding=[0.0, 1.0, 0.0])])
    SENT.clear()
    r = await kg.vector_search(Doc, [1.0, 0.0, 0.0], radius=0.5)
    print("sent:", SENT[-1]); print("rows:", r.rows)
    check("radius=0.5 finds doc 1 only", [row[0] for row in r.rows] == [1])
    SENT.clear()
    r = await kg.vector_search(Doc, [1.0, 0.0, 0.0], radius="0.5), evil(X")
    print("sent:", SENT[-1]); print("rows:", r.rows)
    check("injection-shaped radius string is encoded as one quoted literal (no evil atom)",
          SENT[-1].endswith('Dist <= "0.5), evil(X"') and SENT[-1].count('"') == 2)
    for bad in (float("nan"), float("inf")):
        SENT.clear()
        try:
            await kg.vector_search(Doc, [1.0, 0.0, 0.0], radius=bad)
            check(f"radius={bad!r} refused", False, f"sent {SENT}")
        except CompileError as exc:
            check(f"radius={bad!r} refused before sending", SENT == [], f"({exc})")
    for kw in ({"limit": -1}, {"limit": "1; drop"}, {"offset": -5}, {"limit": True}):
        SENT.clear()
        try:
            await kg.query(Emp.name, **kw)
            check(f"{kw} refused", False, f"sent {SENT}")
        except CompileError as exc:
            check(f"{kw} refused before sending", SENT == [], f"({exc})")
    r = await kg.query(Emp.id, order_by=Emp.id, limit=2, offset=1)
    print("sent:", SENT[-1]); check("limit=2 offset=1 pages", r.rows == [[2], [3]], str(r.rows))

    print("\n== R-TYPE datetime (F1) ==")
    t0 = datetime(1970, 1, 1, 0, 0, 1, tzinfo=timezone.utc)
    t1 = datetime(2026, 10, 4, 12, 30, 0, 250000, tzinfo=timezone.utc)
    SENT.clear()
    await kg.insert([Event(label="a", at=t0, ended=t1, seen=Timestamp(5)),
                     Event(label="b", at=t1, ended=t0, seen=Timestamp.from_datetime(t1))])
    print("sent:", SENT[-1])
    whole = await kg.query(Event, where=lambda e: e.label == "a")
    print("whole.rows:", whole.rows)
    check("whole-relation rows convert datetime, keep Timestamp int", whole.rows == [["a", t0, t1, 5]])
    objs = list(whole)
    check("typed iteration gives datetime incl second datetime col", objs[0].at == t0 and objs[0].ended == t1, repr(objs[0]))
    proj = await kg.query(Event.ended, Event.seen, where=lambda e: e.label == "b")
    print("projected.rows:", proj.rows, "to_dicts:", proj.to_dicts())
    check("projected datetime column converted", proj.rows == [[t0, 1791117000250]])
    r = await kg.query(Event.label, where=lambda e: e.at > datetime(2026, 1, 1, tzinfo=timezone.utc))
    print("sent:", SENT[-1])
    check("datetime condition compares ms", r.rows == [["b"]])

    print("\n== R-META ==")
    class Lead(Derived):
        name: str
        rules: ClassVar[list] = []
    Lead.rules = [From(Emp).where(lambda e: e.id.in_(Boss.emp_id)).select(name=Emp.name)]
    await kg.define_rules(Lead)
    SENT.clear()
    d = await kg.rule_definition(Lead)
    print("sent:", SENT[-1]); print("rule_definition:", d)
    check("rule_definition returns the live clause", len(d) == 1 and d[0].startswith("lead("))
    s = await kg.execute(".session")
    print(".session ->", s.rows)
    try:
        await kg.execute(".session list")
        check(".session list rejected by engine", False)
    except QueryError as exc:
        check(".session list rejected by engine", True, f"({exc})")

    kg._conn.execute = real
    await il.drop_knowledge_graph("nm_drive_py")
    await il.close()
    print(f"\nFAILURES: {FAIL}")
    sys.exit(1 if FAIL else 0)

asyncio.run(main())
Evidence: Live pytest run (compile_rules_live + docs_queries_live) against real server
============================= test session starts ==============================
platform linux -- Python 3.10.22, pytest-9.1.1, pluggy-1.6.0 -- ~/.no-mistakes/worktrees/9eea230c3aae/01M43DNKY22A7HB9SSTGKAZ0DV/packages/inputlayer-py/.venv/bin/python
cachedir: .pytest_cache
rootdir: ~/.no-mistakes/worktrees/9eea230c3aae/01M43DNKY22A7HB9SSTGKAZ0DV/packages/inputlayer-py
configfile: pyproject.toml
plugins: langsmith-0.12.1, asyncio-1.3.0, anyio-4.13.0
asyncio: mode=auto, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... collected 48 items

tests/compile_rules_live.py::test_every_fixture_command_runs_and_rejected_spellings_fail PASSED [  2%]
tests/compile_rules_live.py::test_rule_definition_reads_the_clauses PASSED [  4%]
tests/compile_rules_live.py::test_session_rules_list_and_drop_by_clause PASSED [  6%]
tests/compile_rules_live.py::test_datetime_columns_define_insert_and_read_back PASSED [  8%]
tests/compile_rules_live.py::test_in_and_not_in_queries PASSED           [ 10%]
tests/compile_rules_live.py::test_in_in_a_rule_and_a_conditional_delete PASSED [ 12%]
tests/compile_rules_live.py::test_ground_negation_binds_a_constant_for_the_request_only PASSED [ 14%]
tests/compile_rules_live.py::test_negation_sharing_no_variable_is_refused_before_sending PASSED [ 16%]
tests/compile_rules_live.py::test_view_with_ground_negation PASSED       [ 18%]
tests/compile_rules_live.py::test_session_view_with_ground_negation PASSED [ 20%]
tests/compile_rules_live.py::test_negated_comparison PASSED              [ 22%]
tests/compile_rules_live.py::test_literals_round_trip_through_the_engine PASSED [ 25%]
tests/compile_rules_live.py::test_unrepresentable_values_are_refused_before_sending PASSED [ 27%]
tests/docs_queries_live.py::test_basic_queries_all_rows PASSED           [ 29%]
tests/docs_queries_live.py::test_basic_queries_with_a_filter PASSED      [ 31%]
tests/docs_queries_live.py::test_selecting_specific_columns PASSED       [ 33%]
tests/docs_queries_live.py::test_a_projection_is_a_set PASSED            [ 35%]
tests/docs_queries_live.py::test_joins PASSED                            [ 37%]
tests/docs_queries_live.py::test_self_joins PASSED                       [ 39%]
tests/docs_queries_live.py::test_computed_columns PASSED                 [ 41%]
tests/docs_queries_live.py::test_ordering_and_pagination PASSED          [ 43%]
tests/docs_queries_live.py::test_sort_and_limit_run_in_the_engine PASSED [ 45%]
tests/docs_queries_live.py::test_aggregations_group_by_department_with_stats PASSED [ 47%]
tests/docs_queries_live.py::test_agg_two_measures_same_column PASSED     [ 50%]
tests/docs_queries_live.py::test_aggregates_leave_no_session_rule PASSED [ 52%]
tests/docs_queries_live.py::test_count_without_a_column PASSED           [ 54%]
tests/docs_queries_live.py::test_aggregations_top_k_per_group PASSED     [ 56%]
tests/docs_queries_live.py::test_aggregate_ordered_and_limited PASSED    [ 58%]
tests/docs_queries_live.py::test_working_with_results PASSED             [ 60%]
tests/docs_queries_live.py::test_query_plans PASSED                      [ 62%]
tests/docs_queries_live.py::test_debug_takes_the_arguments_of_query PASSED [ 64%]
tests/docs_queries_live.py::test_debug_shows_the_plan_of_an_aggregate_query PASSED [ 66%]
tests/docs_queries_live.py::test_why_returns_the_selected_columns_with_a_proof_per_row PASSED [ 68%]
tests/docs_queries_live.py::test_why_explains_an_aggregate_query PASSED  [ 70%]
tests/docs_queries_live.py::test_why_returns_a_column_of_the_second_joined_relation PASSED [ 72%]
tests/docs_queries_live.py::test_why_returns_a_computed_column PASSED    [ 75%]
tests/docs_queries_live.py::test_an_offset_without_a_limit_pages_in_the_engine PASSED [ 77%]
tests/docs_queries_live.py::test_why_orders_and_paginates_like_query PASSED [ 79%]
tests/docs_queries_live.py::test_why_returns_the_distinct_rows_of_a_projection_like_query PASSED [ 81%]
tests/docs_queries_live.py::test_paging_a_projection_ordered_by_an_unselected_column_is_refused PASSED [ 83%]
tests/docs_queries_live.py::test_why_orders_and_limits_an_aggregate_query_like_query PASSED [ 85%]
tests/docs_queries_live.py::test_raw_iql PASSED                          [ 87%]
tests/docs_queries_live.py::test_derived_relations_querying_a_recursive_rule PASSED [ 89%]
tests/docs_queries_live.py::test_sessions_session_facts_mix_with_persistent_data PASSED [ 91%]
tests/docs_queries_live.py::test_session_insert_keeps_every_fact PASSED  [ 93%]
tests/docs_queries_live.py::test_distance_functions PASSED               [ 95%]
tests/docs_queries_live.py::test_temporal_functions PASSED               [ 97%]
tests/docs_queries_live.py::test_or_conditions_merge_their_branches PASSED [100%]

============================= 48 passed in 14.38s ==============================
Evidence: Fuzz round-trip with fixed seeds
seed 1: 1 passed, 12 deselected in 0.23s
seed 42: 1 passed, 12 deselected in 0.24s
seed 31337: 1 passed, 12 deselected in 0.24s
seed 2024: 1 passed, 12 deselected in 0.23s
Evidence: Program text for in_ / not-in
?emp(Id, Name, Dept), boss(_, Id) -> [['Alice']]
?emp(Id, Name, Dept), !boss(_, Id) -> [['Bob'], ['Cara']]
~Boss.emp_id.in_(Boss.emp_id) -> CompileError 'shares no variable', nothing sent
Evidence: Literal encoder on hostile input
+emp[(10, "x\"), emp(99, \"pwn\", \"x", "sec"), (11, "line1\nline2\t\"q\" \\ é 漢 🚀 // /* */ <- ?", "sec")]
read back verbatim; no row 99 created
radius=nan/inf, limit=-1/'1; drop'/True, offset=-5 -> CompileError before sending

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 3 issues found → auto-fixed ✅
  • ⚠️ packages/inputlayer-py/src/inputlayer/result.py:109 - R-TYPE says a datetime column's values are 'converted both ways', and the updated guide (docs/content/docs/guides/python-sdk.mdx:162) says a datetime is 'read back as a UTC datetime'. In practice the ms→datetime conversion (_from_engine) only runs in _row_to_obj, and only when the query selects one whole relation (_relation_cls is set; knowledge_graph.py:415). Concrete case: kg.query(Reading.at) or kg.query(Reading.sensor, Reading.at) gives rows/to_dicts()/to_df()/scalar() values like 1791117000250 (int), not datetime. Even kg.query(Reading).rows/to_dicts() returns raw ints, because only iteration and indexing go through _from_engine. Aggregates over a datetime column (min/max) are also left as ints. Also in result.py:121, the isinstance(info.annotation, type) check skips datetime | None / Optional[datetime] fields, so Pydantic's seconds-vs-ms heuristic decides those. Fix: convert in one place using the plan's output column types (outputs that map to a datetime-annotated field) when building ResultSet.rows, and unwrap Optional annotations. Otherwise, narrow the docs claim to typed rows.
  • ⚠️ packages/inputlayer-py/src/inputlayer/knowledge_graph.py:600 - R-LIT requires the encoder to be 'the only place program text is built from values'. vector_search still writes the caller's radius straight into the program (f&#34;Dist &lt;= {radius}&#34;). So radius=float(&#39;nan&#39;) sends Dist &lt;= nan, and float(&#39;inf&#39;) sends Dist &lt;= inf: text the engine misreads as an atom or variable, where the encoder would raise CompileError. A non-numeric radius is interpolated unchecked too. The query vector one line above was switched to encode_literal, but this line was not. Fix: f&#34;Dist &lt;= {encode_literal(radius)}&#34;. A sibling that must hold the same invariant: compiler.py:1081 _limit_atom interpolates limit/offset without validation. Route them through the int encoder, or check they are non-negative ints.
  • ℹ️ packages/conformance/meta-commands.json:2 - The shared fixture's description still says the Python SDK 'is planned to' render commands from its table. This change makes it do so (tests/test_meta.py renders every fixture command through _meta.COMMANDS and checks the table pins exactly the fixture's names). Update the sentence so the shared contract doesn't misdescribe the Python SDK.

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

✅ No issues found.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 9 of 9 scenarios driven live against the product
Scenario Result Live Evidence
R-TYPE: insert datetime/Timestamp rows; engine stores ms ints; whole-relation, projected, to_dicts and min() reads return UTC datetimes while Timestamp stays an int; a datetime filter compiles to ms ✅ pass live scenarios_transcript.log S1; compile_rules_live.py::test_datetime_columns_define_insert_and_read_back
R-META: every shared-fixture command runs on the engine, the rejected spellings (.session list, .session remove) fail, and rule_definition() returns the live clauses ✅ pass live compile_rules_live.py::test_every_fixture_command_runs_and_rejected_spellings_fail, test_rule_definition_reads_the_clauses, test_session_rules_list_and_drop_by_clause; transcript S2
R-IN: e.id.in_(Manager.employee_id) sends manager(_, Id) and returns Alice and Bob; ~in_ sends !manager(_, Id) and returns Cara; ~in_ in a conditional delete removes only non-managers ✅ pass live transcript S3, S8; test_in_and_not_in_queries, test_in_in_a_rule_and_a_conditional_delete
R-NEG read path: ground negation binds through a program-local il_const_s fact, returns rows until the kill switch is inserted, and leaves no session residue ✅ pass live transcript S4; test_ground_negation_binds_a_constant_for_the_request_only
R-NEG view: a persistent view writes an il_const_s row in the same program, orders the clause as positive atoms, negated atoms, then comparisons, and reacts to kill-switch inserts and deletes ✅ pass live transcript S6; test_view_with_ground_negation, test_session_view_with_ground_negation
Adversarial R-NEG: a negated atom sharing no variable, and a conditional delete that needs a constant binding, both raise CompileError with nothing sent to the engine ✅ pass live transcript S5, S7 (sent-program count unchanged)
R-LIT fuzz: 200 random rows (string/int/float/bool/vector) round-trip through the engine and are found again as condition constants, across 6 seeds ✅ pass live live_pytest.log (seed 3440053424), fuzz_seeds.log (seeds 1, 42, 7777, 123456, 99999999)
Adversarial R-LIT: quotes, backslashes, newlines, unicode and an injection attempt are stored as data; NaN/±inf literals are refused with CompileError before sending ✅ pass live transcript S9; test_unrepresentable_values_are_refused_before_sending
F2: vector_search radius goes through the encoder (radius=0.5 finds the doc; radius NaN/inf is refused); limit of -1, '5' or 1.5 is refused before sending ✅ pass live transcript S10
  • cargo build --release --bin inputlayer-server then started the server with a disposable --data-dir on 127.0.0.1:8097
  • INPUTLAYER_TEST_SERVER=ws://127.0.0.1:8097/ws uv run --extra dev pytest tests/compile_rules_live.py tests/docs_queries_live.py -v -s (48 live tests)
  • INPUTLAYER_FUZZ_SEED={1,42,7777,123456,99999999} pytest tests/compile_rules_live.py -k round_trip (literal fuzz round-trip with more seeds)
  • uv run --extra dev python drive_scenarios.py: a scenario driver that spies on every program the SDK sends to the live engine and checks results (S1-S10)
  • uv run --extra dev pytest tests/test_compile_rules.py tests/test_meta.py tests/test_escape_iql.py (targeted unit pins on the compiled text)

✅ No issues found.

  • Live validation: ✅ go - 10 of 10 scenarios driven live against the product
Scenario Result Live Evidence
R-META: every command in the shared conformance fixture runs on the engine through the SDK's table, and the rejected spellings (e.g. .session list) fail ✅ pass live compile_rules_live.py::test_every_fixture_command_runs_and_rejected_spellings_fail PASSED; drive_sdk_transcript.txt '.session list rejected by engine'
R-META: rule_definition() of a live rule returns its clauses (not []), and list/drop by clause work for persistent and session rules ✅ pass live test_rule_definition_reads_the_clauses, test_session_rules_list_and_drop_by_clause PASSED; driver: .rule def lead -> ['lead(Name) <- emp(Id, Name, ), boss(, Id)']
R-TYPE/F1: datetime columns are stored as Unix ms and read back as UTC datetimes in whole-relation rows, projected rows, to_dicts, iteration and min() aggregates, while Timestamp columns stay ints ✅ pass live test_datetime_columns_define_insert_and_read_back PASSED; driver: insert sent ("a", 1000, 1791117000250, 5), projected.rows [[datetime(...), 1791117000250]], condition At &gt; 1767225600000
R-IN: col.in_(Other.col) emits a positive atom with an anonymous/unified variable, and not-in emits the negated atom, both in queries, rules and a conditional delete ✅ pass live test_in_and_not_in_queries, test_in_in_a_rule_and_a_conditional_delete PASSED; driver sent ?emp(Id, Name, Dept), boss(_, Id) / !boss(_, Id)
R-NEG adversarial: a negation sharing no variable with a positive atom is refused with a CompileError before anything is sent ✅ pass live test_negation_sharing_no_variable_is_refused_before_sending PASSED; driver spy shows no request sent
R-NEG: a ground negation binds its constant through il_const_s, as a request-local session fact for reads (nothing left behind) and as persistent rows for persistent and session views, which react to… ✅ pass live test_ground_negation_binds_a_constant_for_the_request_only, test_view_with_ground_negation, test_session_view_with_ground_negation, test_negated_comparison PASSED
R-LIT fuzz: random strings (escapes, newlines, unicode), i64 extremes, finite floats incl. -0.0, bools and vectors round-trip through the engine and also match as condition constants ✅ pass live test_literals_round_trip_through_the_engine PASSED (random seed + seeds 1, 42, 31337, 2024 in fuzz_seeds.txt)
R-LIT adversarial: injection-shaped strings are stored verbatim without creating extra facts, and NaN/±Infinity/lone surrogates are refused with a CompileError before sending ✅ pass live test_unrepresentable_values_are_refused_before_sending PASSED; driver: no row 99, verbatim read-back, nan/inf/-inf refused before sending
F2 adversarial: vector_search radius and query limit/offset go through the encoder (nan/inf radius and negative or non-int limit/offset are refused before sending; an injection-shaped radius becomes o… ✅ pass live drive_sdk_transcript.txt: Dist &lt;= 0.5 finds doc 1; Dist &lt;= &#34;0.5), evil(X&#34; -> []; nan/inf/limit=-1/offset=-5 refused with nothing sent; limit(2, 1) -> [[2],[3]]
No regression in the documented Python SDK guide queries ✅ pass live tests/docs_queries_live.py: all tests PASSED in live_pytest.txt
  • cargo build --release --bin inputlayer-server, then the server started on 127.0.0.1:18093 with a mktemp data directory (as make python-sdk-live does)
  • uv run --extra dev pytest tests/compile_rules_live.py tests/docs_queries_live.py -v against the live server (48 passed)
  • INPUTLAYER_FUZZ_SEED={1,42,31337,2024} pytest tests/compile_rules_live.py -k round_trip: literal round-trip fuzz, 200 rows per seed
  • python drive_sdk.py (evidence dir): adversarial live driver that wraps the KG connection's execute() to record the program text sent, covering in_/~in_, unlinked negation, injection-shaped and unicode/escaped string literals, NaN/±inf in conditions, vector_search radius (0.5, nan, inf, injection string), invalid limit/offset, datetime whole/projected/condition reads, rule_definition via .rule def, and .session vs the rejected .session list
  • uv run --extra dev pytest tests/test_compile_rules.py tests/test_meta.py tests/test_escape_iql.py -q (unit pins on compiled text, 106 passed)
✅ **Document** - passed

✅ No issues found.

✅ No issues found.

🔧 **Lint** - 1 issue found ✅
  • ℹ️ packages/inputlayer-py/src/inputlayer/result.py:85 - ruff format and mypy --strict report many issues across packages/inputlayer-py, but almost all were already there at the base commit and neither tool is enforced in CI or the Makefile. ruff check reports one E501 on changed files, at result.py:85 (an existing install-hint string). I left these alone; cleaning them up across the package would be a separate follow-up.

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

✅ No issues found.

@jsam
jsam marked this pull request as ready for review October 4, 2026 12:36
jsam added 3 commits October 4, 2026 12:37
…terals the engine accepts

Phase-1 item P1-C2 of the SDK redesign, compile path v2 part 2.

- R-TYPE: `Timestamp` and `datetime` columns are declared `int` and hold
  Unix milliseconds (the engine's schema parser has no timestamp type);
  `datetime` values are written as milliseconds (naive ones as UTC) and
  typed rows read them back as UTC datetimes. `Vector[N]` is declared
  `vector(N)`, the form the parser reads (it rejected `vector[N]`).
  Migrations declare the same types.
- R-META: meta commands come from one table (`_meta`) rendered against the
  shared fixture packages/conformance/meta-commands.json, like the JS SDK.
  `session.list_rules()` sent `.session list` and `drop_rule(name, index=)`
  sent `.session remove`, both rejected; `rule_definition()` sent
  `.rule show`, which the engine answers with an empty query result. They
  now send `.session`, `.session drop <n>` and `.rule def` and parse the
  replies with the fixture's grammar; `list_rules()` no longer returns the
  `Rules:` header as a rule.
- R-IN: `a.in_(b)` compiled to `rel(..., X, ...)`, which is not IQL. It is
  now an atom of b's relation with a in b's column and `_` elsewhere
  (`manager(_, Id)`); `~a.in_(b)` negates it. `matches()` fills unbound
  columns with `_` instead of emitting a short atom.
- R-NEG: `~` is pushed down to atoms and comparisons (`!(X = 1)` was a
  parse error), every negated atom must share a variable with a positive
  atom (a CompileError otherwise, before anything is sent), and one linked
  to the body by a constant only binds it through an SDK-owned constant
  relation: a session fact in the query's own program
  (`il_const_s("x")` then `..., il_const_s(K), K = "x", !r(K)`), a
  persistent row written with a view's clauses. View bodies are canonical:
  positive atoms, negated atoms, then comparisons.
- R-LIT: one encoder (`_literal`) builds every literal: the engine's five
  string escapes (control characters such as NUL are written raw, since
  the engine keeps `\0` and `\xHH` verbatim), 64-bit ints, finite floats,
  vectors; None, NaN, infinities, out-of-range ints and lone surrogates are
  refused with CompileError. The LangChain and LangGraph helpers, the
  migration recorder, why_not() and vector_search() use it.

tests/compile_rules_live.py pins each rule against a live engine (every
command of the shared fixture, the rejected spellings, the T-B2 negation
form, and a fuzz test that round-trips random values: encode, insert, read
back, compare) and runs in `make python-sdk-live`.
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