Repository navigation
fix(inputlayer-py): compile each query to one program with columns by position - #235
Merged
Merged
Conversation
… position Compile path v2, part 1 for the Python SDK (R-ONE, R-QUERY, R-SORT, R-AGG): - One SDK call is one program. Aggregates are a program-local rule and its query in one program (il_q), so nothing is left in the session and no cleanup statement exists (fix-report item 2). define(), define_rules(), delete(list), edit_rule_clause() and session.insert() send one program. - Queries bind every column of every atom to a variable. The SDK predicts the engine's column order and picks the selected columns by position, in select order, labelled by column name; a projection is a set (sign-off F8; derived rows no longer expose engine variable names like row.Order). - order_by is a sort annotation on the first atom (the ordered relation moves first) and limit/offset run in the engine. An OR split, or a page of a projection, goes through a program-local union rule so the engine deduplicates, orders and pages it in the same program. - Every aggregate query position is a fresh variable, so two measures of one column no longer drop groups (item 1); count() without a column counts the first variable instead of emitting count<> (item 5), in rules too. - debug() and why() take the arguments of query() (sign-off F9); why() orders and paginates like query(). - CompileError for queries the SDK cannot compile (no relation, OR in a join condition, order_by outside the query); nothing is sent. - tests/docs_queries_live.py runs every query example of python-sdk.mdx against a live engine (the JS docs cases plus the item 1, 2 and 5 pins); `make python-sdk-live` and the python-sdk-live full-suite job run it. - python-sdk.mdx: result column names, the top_k example (it was not valid Python), the last-hour filter, and the two known limitations this removes.
This was referenced Oct 4, 2026
Merged
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
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-C1 P1-C1 (Py).)
Substance of the referenced item (phase-1 PR P1-C1 of the agreed SDK design, Python SDK, compile path v2 part 1): every SDK call is one execute with one program, with no setup/cleanup statement pairs anywhere (R-ONE). A plain query is a
?query binding every column of every atom to a variable in a canonical order; projection, renaming and typing happen client-side by position, and a projected result is a set (R-QUERY). order_by becomes a :asc/:desc annotation on the first atom, with the ordered relation moved first, and limit/offset run in the engine, never client-side (R-SORT). Any aggregate compiles to a program-local rule plus its query in one program, every query position a fresh variable, and count() without a column counts the first variable of the first atom (R-AGG). This designs out three known bugs: an aggregate query that repeats a variable and silently drops groups (pinned live by avg and max over salary grouped by department returning eng 106.67/120 and hr 90/90), aggregate setup rules leaking as session rules (pinned by .session staying empty after 100 aggregate queries), and count() emitting count<>. It also fixes sign-off finding F8 (Python ignores column projection) and F9 (kg.debug(join=...) raising TypeError, because debug/why/query must share one builder), and derived rows exposing engine variable names such as row.Order. Deliverables include unit tests on the compiled text, a live test file tests/docs_queries_live.py mirroring the JS SDK's live docs-query cases against a real engine, and a python-sdk-live CI job that runs it. Other phase-1 items (column types such as timestamp/vector[N], meta-command fixes, in_() compilation, negation, literal encoder, connection core, subscriptions, claims) and later phases are out of scope; millisecond query latency must not regress.What Changed
kg.query()/debug()/why()call as a single program, built by one shared builder. Every column of every atom is bound to a variable, and the SDK picks the selected columns by position, labelled by column name. Projections are now sets, so derived rows no longer expose engine variable names such asrow.Order.order_bycompiles to a sort annotation on the first atom, andlimit/offsetare applied by the engine. An offset with no limit is sent with the largest limit the engine parses. OR splits and paged projections go through a union rule that lives only in that program.count()without a column no longer emitscount<>.debug()andwhy()take the same arguments asquery(), which fixesdebug(join=...).why()now orders, pages and dedupes likequery().CompileError(with ahint) is raised for calls the SDK can't compile, and nothing is sent. Examples:define_rules()andsession.insert()now send one single-statement program per clause or fact, because the engine only keeps session state from single-statement programs.tests/docs_queries_live.pyruns thepython-sdk.mdxquery examples against a live engine, including pins for these bugs. It runs throughmake python-sdk-liveand a newpython-sdk-livejob infull-suite.yml.python-sdk.mdxand the README now show the correct result column names and a fixedtop_kexample, and no longer list the limitations this change removes.Risk Assessment
Testing
I built the engine in release mode and ran the new
make python-sdk-livetarget (the python-sdk-live CI job's entry point). It started a disposable server, and all 35 live docs-query and compile-rule pins passed. I also drove each intent scenario against a fresh disposable server with a script that logs every program sent on the wire. That transcript shows one program per SDK call; correct avg/max per department (eng 106.67/120, hr 90/90); count() returning 5; projection returning only the selected columns as a set; sorting and limit/offset compiled into the engine program (offset without a limit sends limit(9223372036854775807, 3)); the ambiguous paged projection refused with CompileError before anything is sent; debug(join=...) working; .why deduplicating a lossy projection; a derived relation named Order exposing src/dst rather than engine variable names; .session still empty after 100 aggregate queries; and both facts from session.insert visible. Session.insert sends one program per fact, as the operator decided. Comparing latency with the base SDK, both sit at about 1.1 to 1.6 ms p50, so there is no regression. In the same comparison the base SDK returned the wrong rows for an ordered projection (all columns, ascending), and the target returned the right ones. There is no UI surface, so the evidence is CLI transcripts. I removed the build output afterwards.Evidence: make python-sdk-live transcript (35 live tests passed)
Evidence: Wire-level transcript: programs sent and results per scenario
Evidence: Demo driver script
Evidence: Latency and correctness: base SDK vs target SDK
Evidence: Aggregate fix excerpt
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
🔧 **Rebase** - 1 issue found → auto-fixed ✅
.github/workflows/full-suite.yml- merge conflict rebasing onto origin/main🔧 Fix applied.
✅ Re-checked - no issues remain.
packages/inputlayer-py/src/inputlayer/compiler.py:531- Intent conformance. The intent requires (R-SORT): "limit/offset run in the engine, never client-side"._Shape.skipreturns(self.offset or 0) if self.limit is None else 0, andQueryPlan.shapethen drops those rows in Python (rows = rows[self.skip :], compiler.py:460). Sokg.query(..., offset=3)with no limit still fetches the whole result and slices it on the client.tests/docs_queries_live.py:483(test_an_offset_without_a_limit_skips_rows) pins this behaviour..whydoes the same:QueryPlan.shape_why(compiler.py:477-486) applies order, offset and limit client-side because.whyignores them. The code comment says this is deliberate ("The engine paginates only with a limit"). Please confirm the exception is accepted, or choose an engine-side form, such as a very large limit with the offset, or rejecting offset-only calls.packages/inputlayer-py/src/inputlayer/compiler.py:709- A paged projection ordered by a column it does not select returns a short page without any error. In the lossy+paged path, the rule head isout_vars + [order_var], so the engine deduplicates and paginates distinct (projection, order value) tuples.QueryPlan.shapethen picks only the selected columns and runs_distinct(compiler.py:473-474) on the page the engine already cut. Example with the live fixture data:kg.query(Employee.department, join=[Employee], order_by=Employee.salary.desc(), limit=2). The engine returns (eng,130000),(eng,120000), which shapes to [(eng,)]: one row instead of two, while the guide now says "a page is exact however large the result". The fix needs a semantic decision: which order value stands for a group (for example max for desc / min for asc via an aggregate rule), or rejecting order_by on an unselected column of a paged projection. Because the remedy is a product choice, this needs user input.packages/inputlayer-py/src/inputlayer/compiler.py:477- For a lossy projection,kg.whydoes not return the query's rows, although its new docstring says it does ("returns its rows (ordered and paginated the same way)", knowledge_graph.py:784-787).why_columnsbinds every atom column, so.whyreturns one row per underlying tuple.shape_whyorders and slices those rows, andproject_whyprojects them, without the set deduplicationshapeapplies (dedupe). Example:kg.why(Employee.department, join=[Employee], order_by=Employee.department.asc(), limit=2)returns [(eng,),(eng,)], whilekg.querywith the same arguments returns [(eng,),(hr,)]. Unpaged, why returns 5 rows where query returns 2. Fix: whenplan.dedupeis set, haveshape_whykeep the first row index for each distinct projected row before slicing.packages/inputlayer-py/src/inputlayer/session.py:37- R-ONE ("every SDK call is one execute with one program") is still broken bySession.define_rules, which sends oneexecuteper clause. This change converted the siblingKnowledgeGraph.define_rules(knowledge_graph.py:639-654),define, listdeleteandSession.insertto a single newline-joined program, but left this path as a loop. A Derived with two clauses (base + recursive) sends two programs, and a failure on the second leaves a half-defined session rule. Fix: join the compiled clauses with "\n" and execute once, asKnowledgeGraph.define_rulesnow does.🔧 Fix applied.
1 warning still open:
packages/inputlayer-py/src/inputlayer/session.py:37- R-ONE ("every SDK call is one execute with one program") is still broken bySession.define_rules, which sends oneexecuteper clause. This change converted the siblingKnowledgeGraph.define_rules(knowledge_graph.py:639-654),define, listdeleteandSession.insertto a single newline-joined program, but left this path as a loop. A Derived with two clauses (base + recursive) sends two programs, and a failure on the second leaves a half-defined session rule. Fix: join the compiled clauses with "\n" and execute once, asKnowledgeGraph.define_rulesnow does.✅ **Test** - passed
✅ No issues found.
cargo build --release --bin inputlayer-server(the engine under test)make python-sdk-live PY_LIVE_PORT=18092(the CI job's entry point: starts a disposable server with a temp data dir and runstests/docs_queries_live.py, 35/35 passed)uv run python demo_wire.pyagainst a fresh disposable server on port 18093, logging each program passed to Connection._send_and_recvLatency comparison script run alternately with the base SDK (ea9d5d3c, extracted to a temp dir via git archive) and the target SDK against the same disposable server, 200 rows, 80 queries per form, two rounds each✅ **Document** - passed
✅ No issues found.
packages/inputlayer-py/src/inputlayer/knowledge_graph.py:444- Two lint issues remain open in packages/inputlayer-py, and no Makefile target or CI job runs either tool. (1) mypy --strict reports 47 errors in 13 files. Most are missing type arguments on bare dict, list or Callable, a pattern the package already used. This change adds the same pattern on _plan's Callable parameters (knowledge_graph.py:444-445). (2) ruff format would rewrite 5 changed files; most of the package was not formatted with it before this branch either. A separate follow-up should either adopt both tools package-wide and gate them in CI, or drop the strict setting.✅ **Push** - passed
✅ No issues found.