Skip to content

fix(inputlayer-py): compile each query to one program with columns by position - #235

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

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

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-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

  • The Python SDK compiler now sends each 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 as row.Order.
  • order_by compiles to a sort annotation on the first atom, and limit/offset are 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.
  • Aggregates compile to a rule and its query in the same program, with a fresh variable at every query position. This stops two measures over the same column from dropping groups. It also stops aggregate rules from leaking into the session, and count() without a column no longer emits count<>.
  • debug() and why() take the same arguments as query(), which fixes debug(join=...). why() now orders, pages and dedupes like query().
  • A new CompileError (with a hint) is raised for calls the SDK can't compile, and nothing is sent. Examples:
    • a query with no relation
    • OR inside a join condition
    • paging while ordering by a column that isn't selected
  • define_rules() and session.insert() now send one single-statement program per clause or fact, because the engine only keeps session state from single-statement programs.
  • Tests: the unit tests now check the compiled text. A new tests/docs_queries_live.py runs the python-sdk.mdx query examples against a live engine, including pins for these bugs. It runs through make python-sdk-live and a new python-sdk-live job in full-suite.yml.
  • Docs: python-sdk.mdx and the README now show the correct result column names and a fixed top_k example, and no longer list the limitations this change removes.

Risk Assessment

⚠️ Medium: The rewritten query compiler fixes the targeted bugs and has live coverage, but four things remain: a paged projection ordered by an unselected column returns a short page, why() on a projection returns different rows from query(), an offset without a limit is still applied on the client against the stated intent, and Session.define_rules still sends one program per clause.

Testing

I built the engine in release mode and ran the new make python-sdk-live target (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.

  • Live validation: ✅ go - 13 of 13 scenarios driven live against the product
Scenario Result Live Evidence
The python-sdk-live job's make target starts a server and every documented query example passes live ✅ pass live make_python_sdk_live.log (35 passed)
Every SDK query, aggregate, debug and why call sends exactly one program ✅ pass live demo_wire_transcript.txt ('programs sent: 1' per call; 100 aggregate queries sent 100 programs)
avg and max of salary grouped by department return every group (eng 106.67/120, hr 90/90) ✅ pass live demo_wire_transcript.txt; test_agg_two_measures_same_column
.session stays empty after 100 aggregate queries ✅ pass live demo_wire_transcript.txt ('.session -> [[No session data defined.]]'); test_aggregates_leave_no_session_rule
count() without a column returns the row count (no count<>) ✅ pass live demo_wire_transcript.txt (il_q(count<Id>) ..., scalar 5)
Selecting columns returns only those columns as a distinct set (F8) ✅ pass live demo_wire_transcript.txt (Emp.department -> [('eng',), ('hr',)]); latency_base_vs_target.txt shows the base SDK returned all columns
order_by/limit/offset run in the engine, and offset without a limit pages via limit(9223372036854775807, N) ✅ pass live demo_wire_transcript.txt ('?il_q(Name, Salary:desc), limit(2, 1)' and 'limit(9223372036854775807, 3)' with the correct rows)
Adversarial: a paged projection ordered by an unselected column is refused with CompileError before anything is sent ✅ pass live demo_wire_transcript.txt ('CompileError: ordering by a column you do not select with limit is ambiguous...', programs sent: 0)
kg.debug(join=...) returns the plan instead of raising TypeError (F9) ✅ pass live demo_wire_transcript.txt; test_debug_takes_the_arguments_of_query
kg.why on a lossy paged projection returns the same distinct rows as query, with one proof per row ✅ pass live demo_wire_transcript.txt (rows [('eng',), ('hr',)], proofs 2)
A derived relation named Order exposes src/dst columns, not engine variable names ✅ pass live demo_wire_transcript.txt (columns ['src','dst'])
session.insert of two facts keeps both, visible to kg.query ✅ pass live demo_wire_transcript.txt ('session facts visible: [S1, S2]'); test_session_insert_keeps_every_fact
Millisecond query latency does not regress versus the base SDK ✅ pass live latency_base_vs_target.txt (base 1.1-1.6 ms p50, target 1.2-1.6 ms p50 across alternating rounds)
Evidence: make python-sdk-live transcript (35 live tests passed)
    Finished `release` profile [optimized] target(s) in 0.18s
Resolved 67 packages in 1ms
Checked 63 packages in 0.79ms
============================= test session starts ==============================
platform linux -- Python 3.10.22, pytest-9.1.1, pluggy-1.6.0 -- ~/.no-mistakes/worktrees/9eea230c3aae/01M43B829RPSFJPWZ29ZD3GEET/packages/inputlayer-py/.venv/bin/python
cachedir: .pytest_cache
rootdir: ~/.no-mistakes/worktrees/9eea230c3aae/01M43B829RPSFJPWZ29ZD3GEET/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 35 items

tests/docs_queries_live.py::test_basic_queries_all_rows PASSED           [  2%]
tests/docs_queries_live.py::test_basic_queries_with_a_filter PASSED      [  5%]
tests/docs_queries_live.py::test_selecting_specific_columns PASSED       [  8%]
tests/docs_queries_live.py::test_a_projection_is_a_set PASSED            [ 11%]
tests/docs_queries_live.py::test_joins PASSED                            [ 14%]
tests/docs_queries_live.py::test_self_joins PASSED                       [ 17%]
tests/docs_queries_live.py::test_computed_columns PASSED                 [ 20%]
tests/docs_queries_live.py::test_ordering_and_pagination PASSED          [ 22%]
tests/docs_queries_live.py::test_sort_and_limit_run_in_the_engine PASSED [ 25%]
tests/docs_queries_live.py::test_aggregations_group_by_department_with_stats PASSED [ 28%]
tests/docs_queries_live.py::test_agg_two_measures_same_column PASSED     [ 31%]
tests/docs_queries_live.py::test_aggregates_leave_no_session_rule PASSED [ 34%]
tests/docs_queries_live.py::test_count_without_a_column PASSED           [ 37%]
tests/docs_queries_live.py::test_aggregations_top_k_per_group PASSED     [ 40%]
tests/docs_queries_live.py::test_aggregate_ordered_and_limited PASSED    [ 42%]
tests/docs_queries_live.py::test_working_with_results PASSED             [ 45%]
tests/docs_queries_live.py::test_query_plans PASSED                      [ 48%]
tests/docs_queries_live.py::test_debug_takes_the_arguments_of_query PASSED [ 51%]
tests/docs_queries_live.py::test_debug_shows_the_plan_of_an_aggregate_query PASSED [ 54%]
tests/docs_queries_live.py::test_why_returns_the_selected_columns_with_a_proof_per_row PASSED [ 57%]
tests/docs_queries_live.py::test_why_explains_an_aggregate_query PASSED  [ 60%]
tests/docs_queries_live.py::test_why_returns_a_column_of_the_second_joined_relation PASSED [ 62%]
tests/docs_queries_live.py::test_why_returns_a_computed_column PASSED    [ 65%]
tests/docs_queries_live.py::test_an_offset_without_a_limit_pages_in_the_engine PASSED [ 68%]
tests/docs_queries_live.py::test_why_orders_and_paginates_like_query PASSED [ 71%]
tests/docs_queries_live.py::test_why_returns_the_distinct_rows_of_a_projection_like_query PASSED [ 74%]
tests/docs_queries_live.py::test_paging_a_projection_ordered_by_an_unselected_column_is_refused PASSED [ 77%]
tests/docs_queries_live.py::test_why_orders_and_limits_an_aggregate_query_like_query PASSED [ 80%]
tests/docs_queries_live.py::test_raw_iql PASSED                          [ 82%]
tests/docs_queries_live.py::test_derived_relations_querying_a_recursive_rule PASSED [ 85%]
tests/docs_queries_live.py::test_sessions_session_facts_mix_with_persistent_data PASSED [ 88%]
tests/docs_queries_live.py::test_session_insert_keeps_every_fact PASSED  [ 91%]
tests/docs_queries_live.py::test_distance_functions PASSED               [ 94%]
tests/docs_queries_live.py::test_temporal_functions PASSED               [ 97%]
tests/docs_queries_live.py::test_or_conditions_merge_their_branches PASSED [100%]

============================== 35 passed in 6.52s ==============================
Evidence: Wire-level transcript: programs sent and results per scenario

=== avg+max salary by department (bug: dropped groups)
  programs sent: 1
  >>> il_q(Department, avg<Salary>, max<Salary>) <- staff(Id, Department, Salary)
      ?il_q(Department, AvgSalary, MaxSalary)
  columns: ['department', 'avg_salary', 'max_salary'] rows: [('eng', 106.66666666666667, 120.0), ('hr', 90.0, 90.0)]

=== count() without a column
  programs sent: 1
  >>> il_q(count<Id>) <- emp(Id, Name, Department, Salary)
      ?il_q(Count)
  columns: ['count'] scalar: 5

=== projection Emp.department (F8: projection, set)
  programs sent: 1
  >>> ?emp(Id, Name, Department, Salary)
  columns: ['department'] rows: [('eng',), ('hr',)]

=== projection Emp.name, Emp.salary order desc limit 2 offset 1
  programs sent: 1
  >>> il_q(Name, Salary) <- emp(Id, Name, Department, Salary)
      ?il_q(Name, Salary:desc), limit(2, 1)
  rows: [('Alice', 120.0), ('Cara', 110.0)]

=== offset without limit (engine-side paging)
  programs sent: 1
  >>> il_q(Name, Salary) <- emp(Id, Name, Department, Salary)
      ?il_q(Name, Salary:desc), limit(9223372036854775807, 3)
  rows: [('Eve', 95.0), ('Bob', 90.0)]

=== ADVERSARIAL: paged projection ordered by unselected column
  CompileError: ordering by a column you do not select with limit is ambiguous; select it or drop limit (add the order_by column to the selection, or drop limit and offset)
  programs sent: 0

=== debug(join=...) (F9: used to raise TypeError)
  programs sent: 1
  >>> .debug ?emp(Id, Name, Department, Salary), Salary > 100
  iql: ?emp(Id, Name, Department, Salary), Salary > 100

=== why lossy projection paged (dedupe like query)
  programs sent: 1
  >>> .why il_q(Department, Id, Name, Salary) <- emp(Id, Name, Department, Salary)
  rows: [('eng',), ('hr',)] proofs: 2

=== derived relation named Order (row attrs, no engine var names)
  programs sent: 1
  >>> ?order(Src, Dst)
  columns: ['src', 'dst'] dicts: [{'src': 1, 'dst': 2}, {'src': 2, 'dst': 3}]

=== 100 aggregate queries: programs sent 100 (one per call)
  .session -> [['No session data defined.']]

=== session.insert two facts
  programs sent: 2
  >>> emp(10, "S1", "tmp", 1.0)
  >>> emp(11, "S2", "tmp", 2.0)
  session facts visible: [('S1',), ('S2',)]

=== latency over 50 paged queries: p50 1.30 ms, p95 1.46 ms
Evidence: Demo driver script
"""Drive the Python SDK against a live engine, logging every program sent on the wire."""
import asyncio, os, sys, time
from typing import ClassVar
from inputlayer import InputLayer, Relation, Derived, From, avg, count, max_
from inputlayer.exceptions import CompileError

URL = os.environ["INPUTLAYER_TEST_SERVER"]

class Staff(Relation):
    id: int
    department: str
    salary: float

class Emp(Relation):
    id: int
    name: str
    department: str
    salary: float

class Edge(Relation):
    src: int
    dst: int

class Order(Derived):
    src: int
    dst: int
    rules: ClassVar[list] = []
Order.rules = [From(Edge).select(src=Edge.src, dst=Edge.dst)]

SENT = []
def hook(conn):
    for name in ("_send_and_recv",):
        orig = getattr(conn, name)
        async def wrapped(prog, *a, _orig=orig, **k):
            if not prog.startswith(".kg use"):
                SENT.append(prog)
            return await _orig(prog, *a, **k)
        setattr(conn, name, wrapped)

async def call(label, coro_fn):
    SENT.clear()
    print(f"\n=== {label}")
    try:
        r = await coro_fn()
    except CompileError as e:
        print(f"  CompileError: {e}")
        print(f"  programs sent: {len(SENT)}")
        return None
    print(f"  programs sent: {len(SENT)}")
    for p in SENT:
        print("  >>> " + p.replace("\n", "\n      "))
    return r

async def main():
    il = InputLayer(URL, username="admin", password="admin"); await il.connect()
    try: await il.drop_knowledge_graph("demo_wire")
    except Exception: pass
    kg = il.knowledge_graph("demo_wire")
    hook(kg._conn)
    await kg.define(Staff, Emp, Edge)
    await kg.insert([Staff(id=1, department="eng", salary=120.0), Staff(id=2, department="eng", salary=100.0),
                     Staff(id=3, department="eng", salary=100.0), Staff(id=4, department="hr", salary=90.0)])
    await kg.insert([Emp(id=1, name="Alice", department="eng", salary=120.0), Emp(id=2, name="Bob", department="hr", salary=90.0),
                     Emp(id=3, name="Cara", department="eng", salary=110.0), Emp(id=4, name="Dana", department="eng", salary=130.0),
                     Emp(id=5, name="Eve", department="hr", salary=95.0)])
    await kg.insert([Edge(src=1, dst=2), Edge(src=2, dst=3)])

    r = await call("avg+max salary by department (bug: dropped groups)",
                   lambda: kg.query(Staff.department, avg(Staff.salary), max_(Staff.salary), join=[Staff]))
    print("  columns:", r.columns, "rows:", sorted(r.to_tuples()))

    r = await call("count() without a column", lambda: kg.query(count(), join=[Emp]))
    print("  columns:", r.columns, "scalar:", r.scalar())

    r = await call("projection Emp.department (F8: projection, set)", lambda: kg.query(Emp.department, join=[Emp]))
    print("  columns:", r.columns, "rows:", sorted(r.to_tuples()))

    r = await call("projection Emp.name, Emp.salary order desc limit 2 offset 1",
                   lambda: kg.query(Emp.name, Emp.salary, join=[Emp], order_by=Emp.salary.desc(), limit=2, offset=1))
    print("  rows:", r.to_tuples())

    r = await call("offset without limit (engine-side paging)",
                   lambda: kg.query(Emp.name, Emp.salary, join=[Emp], order_by=Emp.salary.desc(), offset=3))
    print("  rows:", r.to_tuples())

    await call("ADVERSARIAL: paged projection ordered by unselected column",
               lambda: kg.query(Emp.department, join=[Emp], order_by=Emp.salary.desc(), limit=2))

    r = await call("debug(join=...) (F9: used to raise TypeError)",
                   lambda: kg.debug(Emp.name, join=[Emp], where=lambda e: e.salary > 100))
    print("  iql:", r.iql)

    r = await call("why lossy projection paged (dedupe like query)",
                   lambda: kg.why(Emp.department, join=[Emp], order_by=Emp.department.asc(), limit=2))
    print("  rows:", r.results.to_tuples(), "proofs:", len(r.proof_trees))

    await kg.define_rules(Order)
    r = await call("derived relation named Order (row attrs, no engine var names)", lambda: kg.query(Order))
    print("  columns:", r.columns, "dicts:", sorted(r.to_dicts(), key=lambda d: d["src"]))

    SENT.clear()
    for _ in range(100):
        await kg.query(Staff.department, count(Staff.id), join=[Staff])
        await asyncio.sleep(0.02)
    print(f"\n=== 100 aggregate queries: programs sent {len(SENT)} (one per call)")
    s = await kg.execute(".session")
    print("  .session ->", s.rows)

    r = await call("session.insert two facts", lambda: kg.session.insert([Emp(id=10, name="S1", department="tmp", salary=1.0),
                                                                          Emp(id=11, name="S2", department="tmp", salary=2.0)]))
    r = await kg.query(Emp.name, join=[Emp], where=lambda e: e.department == "tmp")
    print("  session facts visible:", sorted(r.to_tuples()))

    ts = []
    for _ in range(50):
        t = time.perf_counter(); await kg.query(Emp.name, Emp.salary, join=[Emp], order_by=Emp.salary.desc(), limit=3)
        ts.append((time.perf_counter() - t) * 1000); await asyncio.sleep(0.02)
    ts.sort(); print(f"\n=== latency over 50 paged queries: p50 {ts[25]:.2f} ms, p95 {ts[47]:.2f} ms")
    await il.drop_knowledge_graph("demo_wire"); await il.close()

asyncio.run(main())
Evidence: Latency and correctness: base SDK vs target SDK
base1 programs: ['?emp(Id, Name, Department, Salary), limit(3)'] rows: [(2, 'n2', 'd', 2.0), (1, 'n1', 'd', 1.0), (0, 'n0', 'd', 0.0)]
  paged: p50 1.64 ms p95 1.94 ms
  plain: p50 1.54 ms p95 1.74 ms
target1 programs: ['il_q(Name, Salary) <- emp(Id, Name, Department, Salary)\n?il_q(Name, Salary:desc), limit(3)'] rows: [('n199', 199.0), ('n198', 198.0), ('n197', 197.0)]
  paged: p50 1.61 ms p95 1.84 ms
  plain: p50 1.26 ms p95 1.43 ms
base2 programs: ['?emp(Id, Name, Department, Salary), limit(3)'] rows: [(2, 'n2', 'd', 2.0), (1, 'n1', 'd', 1.0), (0, 'n0', 'd', 0.0)]
  paged: p50 1.22 ms p95 1.36 ms
  plain: p50 1.11 ms p95 1.30 ms
target2 programs: ['il_q(Name, Salary) <- emp(Id, Name, Department, Salary)\n?il_q(Name, Salary:desc), limit(3)'] rows: [('n199', 199.0), ('n198', 198.0), ('n197', 197.0)]
  paged: p50 1.44 ms p95 1.71 ms
  plain: p50 1.17 ms p95 1.41 ms
Evidence: Aggregate fix excerpt
programs sent: 1
>>> il_q(Department, avg<Salary>, max<Salary>) <- staff(Id, Department, Salary)
?il_q(Department, AvgSalary, MaxSalary)
columns: ['department', 'avg_salary', 'max_salary'] rows: [('eng', 106.67, 120.0), ('hr', 90.0, 90.0)]

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.

⚠️ **Review** - 1 warning
  • ⚠️ 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.skip returns (self.offset or 0) if self.limit is None else 0, and QueryPlan.shape then drops those rows in Python (rows = rows[self.skip :], compiler.py:460). So kg.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. .why does the same: QueryPlan.shape_why (compiler.py:477-486) applies order, offset and limit client-side because .why ignores 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 is out_vars + [order_var], so the engine deduplicates and paginates distinct (projection, order value) tuples. QueryPlan.shape then 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.why does 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_columns binds every atom column, so .why returns one row per underlying tuple. shape_why orders and slices those rows, and project_why projects them, without the set deduplication shape applies (dedupe). Example: kg.why(Employee.department, join=[Employee], order_by=Employee.department.asc(), limit=2) returns [(eng,),(eng,)], while kg.query with the same arguments returns [(eng,),(hr,)]. Unpaged, why returns 5 rows where query returns 2. Fix: when plan.dedupe is set, have shape_why keep 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 by Session.define_rules, which sends one execute per clause. This change converted the sibling KnowledgeGraph.define_rules (knowledge_graph.py:639-654), define, list delete and Session.insert to 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, as KnowledgeGraph.define_rules now 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 by Session.define_rules, which sends one execute per clause. This change converted the sibling KnowledgeGraph.define_rules (knowledge_graph.py:639-654), define, list delete and Session.insert to 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, as KnowledgeGraph.define_rules now does.
✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 13 of 13 scenarios driven live against the product
Scenario Result Live Evidence
The python-sdk-live job's make target starts a server and every documented query example passes live ✅ pass live make_python_sdk_live.log (35 passed)
Every SDK query, aggregate, debug and why call sends exactly one program ✅ pass live demo_wire_transcript.txt ('programs sent: 1' per call; 100 aggregate queries sent 100 programs)
avg and max of salary grouped by department return every group (eng 106.67/120, hr 90/90) ✅ pass live demo_wire_transcript.txt; test_agg_two_measures_same_column
.session stays empty after 100 aggregate queries ✅ pass live demo_wire_transcript.txt ('.session -> [[No session data defined.]]'); test_aggregates_leave_no_session_rule
count() without a column returns the row count (no count<>) ✅ pass live demo_wire_transcript.txt (il_q(count<Id>) ..., scalar 5)
Selecting columns returns only those columns as a distinct set (F8) ✅ pass live demo_wire_transcript.txt (Emp.department -> [('eng',), ('hr',)]); latency_base_vs_target.txt shows the base SDK returned all columns
order_by/limit/offset run in the engine, and offset without a limit pages via limit(9223372036854775807, N) ✅ pass live demo_wire_transcript.txt ('?il_q(Name, Salary:desc), limit(2, 1)' and 'limit(9223372036854775807, 3)' with the correct rows)
Adversarial: a paged projection ordered by an unselected column is refused with CompileError before anything is sent ✅ pass live demo_wire_transcript.txt ('CompileError: ordering by a column you do not select with limit is ambiguous...', programs sent: 0)
kg.debug(join=...) returns the plan instead of raising TypeError (F9) ✅ pass live demo_wire_transcript.txt; test_debug_takes_the_arguments_of_query
kg.why on a lossy paged projection returns the same distinct rows as query, with one proof per row ✅ pass live demo_wire_transcript.txt (rows [('eng',), ('hr',)], proofs 2)
A derived relation named Order exposes src/dst columns, not engine variable names ✅ pass live demo_wire_transcript.txt (columns ['src','dst'])
session.insert of two facts keeps both, visible to kg.query ✅ pass live demo_wire_transcript.txt ('session facts visible: [S1, S2]'); test_session_insert_keeps_every_fact
Millisecond query latency does not regress versus the base SDK ✅ pass live latency_base_vs_target.txt (base 1.1-1.6 ms p50, target 1.2-1.6 ms p50 across alternating rounds)
  • 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 runs tests/docs_queries_live.py, 35/35 passed)
  • uv run python demo_wire.py against a fresh disposable server on port 18093, logging each program passed to Connection._send_and_recv
  • Latency 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.

⚠️ **Lint** - 1 info
  • ℹ️ 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.

jsam added 3 commits October 4, 2026 11:37
… 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.
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