# dkepy
Write facts into a [DKE](https://dke.langsyn.com/) store, from Python, for
kinds you define yourself.
Distribution name `dkepy`, import name `dke` — they differ because `dke` is
taken on PyPI, and the package provides the module `dke` so that
`from dke import ...` reads the way it should.
**Where to get it.** The source is served, file by file and as a reproducible
tarball, at . It is **not on PyPI**: the
distribution name is unregistered there as of 2026-08-25, so `pip install dkepy`
installs nothing of ours and would install whatever a stranger registers under
that name. Put the `src/` directory on your path, or install from the tarball.
## What it is for
A DKE reasoning module supplies the reasoning — the schema, the standing
rules, the queries. You supply the facts. This package is the second half: it
turns whatever you have read into a DKE Python program that records it, which
you submit to your store through whichever DKE client you already use.
```python
from dke import FactProgram
prog = FactProgram("inventory", source="warehouse-scan")
prog.comment("Stock levels, read 2026-08-21.")
prog.open()
prog.table(
"items",
['("widget", "Widget", 12)', '("sprocket", "Sprocket", 0)'],
[prog.put("Item.s.label", "r[1]"),
prog.put("Item.s.count", "r[2]")],
)
print(prog.render())
```
The output is a program you can read before you run it. Every fact it would
record is a line you can see.
## Why not just write the program by hand
You can, and for a handful of facts you should. What this package is for is
the case where a machine produces them, because three things are easy to get
wrong there and wrong in the direction that stays quiet:
- **A subject that moves between runs.** Re-ingesting then records a second
set of facts beside the first instead of revising it, and every query that
joined on the old subject still answers — with stale numbers.
- **The write verb.** A producer means "replace my own earlier reading", and
that is what `update` says. `put()` emits it.
- **An optional cell written as an empty string.** A rule that concludes from
the *absence* of a cell is silenced by a value stored everywhere, including
a falsy one. `table()` is shaped so a row can carry a placeholder while the
write stays behind a guard.
A producer with a broken reader records nothing, which is loud. A producer
with a broken fact-writer records plausible facts, and every query still
answers.
## The code readers
`dke.ingest` holds producers for one domain — source code, for the `code`
reasoning module:
```
dke-ingest-python ./src > facts.dpy
dke-ingest-cpp ./src ./include > facts.dpy
```
Both are lower bounds by construction. Every edge they record is one the
source makes plain; a call through a variable, a dict of handlers, `getattr`
or any dynamic dispatch is not visible to a reader and is not recorded. Each
module documents its own limits — read those before trusting a count.
They are also the worked example. Your own reader supplies the rows, and
everything from the rows onward is the same code.
## Submitting what it produced
What comes out is a PROGRAM — statements at top level — so it goes to your
store through the same verb as any other question you ask. Nothing needs
compiling: `compile` stores a definition, and a fact load defines nothing.
Over MCP, that is one call:
```
run(source=)
```
and this package can make it for you. The client is a separate import, on
purpose — see **No network, no subprocess** below:
```python
from dke.client import Client
client = Client.from_env() # reads DKE_API_KEY
result = client.run(program).raise_for_status()
print(result.text, result.ops)
```
Either way the reply lists the claims it wrote, which is the same list you
could read in the file first.
This section named an internal command-line tool until 0.5.0. It is not one
you have — it ships to nobody — so the only routes were your own MCP client
or hand-built JSON, which is what `dke.client` replaces.
The reasoning module is the other half and goes in the other way, because a
module IS a definition — its schema, rules and queries are meant to persist:
```
compile(name="code", source=)
```
Do that once. Re-running a producer afterwards revises the facts underneath it
and leaves the reasoning alone, which is what `update` is for.
## Telling whether a load finished
A fact load is not all-or-nothing. If a submission stops part-way — a dropped
connection, a service restart, a refusal on one slot — the claims it already
made stay made. Re-running the producer repairs that, because it writes
`update` and revises its own earlier reading rather than adding beside it. What
re-running cannot do is tell you there was anything to repair: a partially
loaded store looks exactly like a complete one, and every query answers from it
with the same confidence.
So each program records a marker for the source it loads:
```
Load..started when the load began
Load..declared how many rows it meant to write
Load..completed written last, and only if the load reached the end
```
`completed` is the final statement of the program, so an interrupted load
simply never writes it. That is the point: you are told a load finished only by
a load that finished, and a run that died says nothing at all. A load is
complete when `completed` is at least `started`; if it is older, or missing,
the last load of that source did not finish and `declared` says how much it was
going to write.
```python
st = current(Load.python.started)
sv = st.value
fi = current(Load.python.completed)
fv = fi.value
match sv:
case int as a:
match fv:
case int as b:
print(b >= a)
case default:
print(False)
case default:
print(False)
```
A load that legitimately wrote fewer facts — because your codebase shrank — is
not the same thing and does not read as one: it finished, so `completed` is
current and `declared` is simply smaller.
`Load` is deliberately not under the reasoning module's name, so you can ask
this question without importing the module first — which matters most in the
case where a load may have failed. If your own module declares a `Load` of its
own, `FactProgram.load_marker` takes the kind name as an argument.
## A load too big for one program
A store refuses a program larger than a size it is set up with. A load that
outgrows one program goes through `FactLoad` instead: the same tables, the same
`put` and `q`, and as many programs as the size you pass needs. Take that size
from your store's documentation or its operator, and stay well inside it.
```python
from dke import FactLoad
load = FactLoad("inventory", source="warehouse-scan", max_bytes=130_000)
load.comment("Stock levels, read 2026-10-08.")
load.table("items", rows,
[load.put("Item.s.label", "r[1]"),
load.put("Item.s.count", "r[2]")])
load.load_marker("warehouse", stamp)
for program in load.render():
client.run(program).raise_for_status()
```
`rows` are your rows exactly as `FactProgram.table` takes them, `stamp` is any
number that grows from one load to the next (epoch milliseconds is the obvious
one), and `client` is a `dke.client.Client`.
Every program is whole: its own import, its own tables, its own loops, so each
runs on its own. A split falls between rows and never inside a table's loop.
Rows that belong together — a sentence and its words — go in through
`load.add({"sentences": [...], "words": [...]})` as one group, and no split
falls inside a group. A group too big for a program of its own is refused
rather than split.
Its rows are written into the program's text, so the program's size is the one
limit a load meets.
With a load marker, each program carries its own, under `warehouse_01`,
`warehouse_02` and so on, and each also records `parts`: how many programs the
load was split into. Each program commits on its own, so each needs its own
proof that it reached its end. The last load finished when the programs whose
`started` holds the newest stamp number `parts`, and every one of them has
`completed` at least that stamp. A program that never ran shows up as one
missing from that count. A load that fits in one program is still written as
part `_01` of 1, so the marker keeps its shape on the day the load grows.
## No network, no subprocess
**Producing facts never needs your key.** `import dke`, `dke.factwriter` and
everything under `dke.ingest` open no socket and spawn no process, so you can
point a reader at your own source without granting it anything, and read the
program it wrote before deciding to send it.
`dke.client` is the exception, and it is one you have to ask for: it is the
only module here that talks to a network, it is reached by nothing the
producing half imports, and `from dke.client import Client` is what grants it.
Naming it IS the grant.
None of that is promised in prose. `test/test_ingest.py` asserts it three
ways, because they fail differently: every producing module is read for a
networking import; the import GRAPH is walked from each producing root to show
the client is unreachable; and a fresh interpreter imports the producing half
and is asked what it actually loaded. A file scan cannot see an edge, and a
graph walk cannot see an import performed at run time.
The exemption for the client is checked in **both** directions — a module that
is exempt and imports no networking module fails too — so it cannot outlive
the reason it was granted.
## Requirements
Python 3.10 or later. No runtime dependencies.
## Licence
MIT. See `LICENSE`.