DKE Python — Tutorial
Version: alpha · API 1. This tutorial teaches DKE Python by example; the Language Reference is the exhaustive entry-per-construct companion, and the Wire & Tool Contract specifies the transport that carries a program to the service. This document as Markdown: dke-python-tutorial.md — the same text this page is rendered from, for readers and tools that would rather have the source than the page.
This tutorial assumes you know Python. It does not re-teach def, for, match, or string building — you have those already. It teaches what those familiar shapes do in DKE, the vocabulary that has no Python equivalent, and the handful of places DKE deliberately departs from Python. The syntax is Python’s; the semantics are a knowledge engine’s.
Every complete example below runs exactly as shown: each is a program body that defines a procedure you can compile and run. The shorter fenced snippets are fragments shown for illustration only.
Chapter 1 — Coming from Python
DKE Python is a small language for recording facts and reading them back — you tell the DKE service to remember a claim, then ask for it later with its provenance and its type intact. It talks to the DKE service at dke.langsyn.net.
The one shift to make up front is where your data lives. A Python program keeps state in variables and objects that vanish when the function returns; a DKE Python program keeps state in the store, as claims. A claim is a value at an addressed cell, stamped with the source that asserted it — and claims persist, carry their own type, remember their history, and can be reasoned over long after your program has finished. Local variables still exist, but they only hold what you are about to write or have just read. The durable state is always the store.
Here is the Python you already know, translated into DKE habits:
| In Python you… | In DKE Python you… |
|---|---|
| keep state in variables and objects | record claims in the store — persistent, typed, and stamped with a source |
| mutate a value in place | supersede it — the prior value stays as history you can walk |
return a value from a function |
the same — a def with a -> T annotation returns, and the caller binds the result |
loop with while, recursion, or an unbounded for |
iterate a finite collection with for … in — every program is guaranteed to terminate |
take a fractional power of an int with a computed exponent (2 ** n) |
write it as a literal (2 ** -1 is 0.5) or make the base a real — a runtime-negative exponent refuses, because the type is fixed before the value is known |
catch a ZeroDivisionError |
a divide-by-zero refuses — a catchable outcome, not an exception |
match on structural patterns |
match on a result’s kind, a value’s class, or an instance’s class |
Keep that table in the corner of your eye; the chapters ahead earn every row — and when you get one of them wrong, you get told what to write instead. A compile that fails refuses the whole submission (nothing is stored) and answers with the line, the column, and the DKE Python form that belongs there. Write while and it says there is no while and hands you for <var> in <finite collection>:. So a first draft written on Python reflexes is usually one edit from compiling rather than a rewrite, and the fastest way to learn this table is to get a row wrong on purpose and read the answer.
Here are three of those rows broken on purpose, and what comes back. The first is refused when you compile; the other two compile and refuse when they run — a distinction the table’s wording is careful about, and one worth having in hand before Chapter 2.
Unbounded iteration is refused at compile time, so nothing is stored at all:
def tally(k: string):
while True: # rejected: DKE Python has no `while`
print(k)
A computed negative exponent compiles — the type is fixed before the value is known — and refuses when it runs:
# negative-exponent.dpy — a computed negative exponent on an int base refuses.
def shrink():
n = 0 - 1
print(2 ** n)
error: script shrink() failed — line 4: a negative exponent needs a real base — write the base as a real (e.g. `2.0 ** -1`), or write the exponent as a literal
Take either way out it offers and it runs: 2.0 ** n makes the base a real, and 2 ** -1 is a literal exponent, which is 0.5.
A divide by zero refuses the same way. A refusal is an outcome your caller can read, not an exception that unwinds:
# divide-by-zero.dpy — a divide by zero refuses at run time.
def ratio():
print(1 // 0)
error: script ratio() failed — line 3: integer division or modulo by zero
If you are driving the service from a program rather than by hand, that correction arrives as its own field — fix, beside the human-readable message — so a client can apply it without parsing prose. The MCP reference §6.1 works an example end to end.
And one capability has no Python row at all: the engine reasons. You can declare facts the engine derives and maintains on its own — recompute-on-change fields, standing rules, and what-if hypotheses. That is where DKE Python stops resembling a scripting language and starts resembling a knowledge engine you program. It is Chapter 8, and it is the reason the language exists.
Your first program
A DKE Python source file is just the program body — there is no header line; it begins with your first declaration or statement. The keywords you see here are English; which keyword vocabulary a source uses is set out of band by the language parameter when the source is submitted, not by anything in the file, and omitting it gives you English. This tutorial teaches the English surface.
A source has at least one procedure, introduced with def. A procedure records and reads facts and prints lines with print (DKE’s print). Here is a complete first program:
# hello-store.dpy — record one fact, read it straight back.
def hello_store():
remember(Greeting.hello.text, "world", "op1")
cur = current(Greeting.hello.text)
match cur:
case active_claim:
print("greeting = " + str(cur.value) + " (per " + str(cur.source) + ")")
case empty:
print("nothing recorded yet")
Three things are happening, and only one of them looks like Python:
remember(Greeting.hello.text, "world", "op1")records a claim: at the cell namedGreeting.hello.text(a kindGreeting, a subjecthello, an attributetext), the value"world", attributed to the source"op1". No Python assignment does this — the fact goes into the store, not into a variable.current(Greeting.hello.text)reads back the live claim at that cell.match cur:inspects the result. Reading a cell gives you either anactive_claimorempty, and amatchmust handle both. DKE’smatchdispatches on a result’s kind — not on Python’s structural patterns — but thecasesyntax is the one you know. Inside theactive_claimarm,cur.valueandcur.sourceare available.
Compiling and running
A DKE Python program runs at the DKE service in two wire steps (MCP §4). First you compile the source — the service typechecks it and stores each procedure the module defines (MCP §4.1):
compile hello from "<source>"
Then you run a stored procedure by its module-qualified name (MCP §4.2) — the module you compiled it under, a dot, the procedure:
run hello.hello_store()
The service runs it and returns a transcript — a header with a verdict tallying what the run did, then the lines the program printed and the facts it recorded, in execution order:
OK script hello_store(): 1 claim written, 1 print
remember Greeting.hello.text = "world"
print greeting = world (per op1)
When you just want to try a program — run it once and see what it does, without keeping it — run also takes the source directly: pass the program to run and it compiles and executes it in one step but stores nothing (MCP §4.2). The Run panel at the end of this tutorial uses this inline form: paste a program, press Run, and its data writes land in the store while the program itself leaves no trace. So: compile is for keeping a program (so later instructions can call it, or its rules can stand); an inline run is for trying one. (A program that defines a standing @rule must be compiled — an inline run refuses it, because a rule only takes effect once stored.)
There are no free-standing statements: all work lives inside a def, and a program runs by calling one. This is the whole shape of the language — the rest of this tutorial fills in the values you can record, the questions you can ask, the reasoning the engine can do on your behalf, and the control flow that ties it all together.
Chapter 2 — Claims, cells, and provenance
Chapter 1 recorded a claim with remember and read it back with current. Those two verbs are the heart of DKE programming, so before anything else, meet the model they work over and the fuller cast around them.
Every fact in the store is a claim: a value at a cell, from a source. A cell is addressed by a three-part path K.s.a — a kind K, a subject s, and an attribute a (Greeting.hello.text, Cat.felix.color). You never declare a kind or a subject up front; naming a cell brings it into being. Where a Python object has fields you assign, a DKE subject has attributes you claim — and the difference that matters is the third argument every write carries.
Writing
remember(K.s.a, v, src)— record a value from a source. A different value from a different source stands alongside as a co-active alternative; to revise a value from the same source, useupdate.update(K.s.a, v, src)— revise the value from that source, superseding the prior one (and establishing a value on an empty cell).forget(K.s.a)/forget(K.s)— permanently remove data (path-scoped). This is a hard erase: every value ever recorded at the cell is reclaimed, not just the current one, andforget(K.s)removes the whole subject and its kind. Nothing is kept in history and nothing can be reinstated. Removal cascades into facts derived from the target instead of refusing while they exist, and needs aread-write-deletekey.forget(K.s.a, t"…")— remove only what was true at that instant, leaving the rest of the cell’s history standing. Reach for this when you want to shed a period rather than a cell: you name a moment, and the values whose validity window covers it go. An instant nothing covers removes nothing and is not an error. The Reference has the full form list, including removing one named claim.forget(K.s.a, recorded_before=t"…")— trim history: remove the older versions written before that instant, keeping whatever is in force now. Unlike every other form above, a range can never empty a cell — the value in force always survives one.valid_before=asks the other question, removing the older versions that stopped being true before the instant, and both have anat_or_beforespelling for the boundary. There is noafterform on purpose: it would reach the current value, which is a different operation with its own spelling.A range is also the one form that takes more than a cell:
forget(K.s, <range>)trims every cell of a subject andforget(K, <range>)trims the whole kind. That is what it is for — a year of history is not something you can drop one cell at a time. The safety property holds at every width, so even the kind-wide form cannot empty anything. A bareforget(K)with no range is refused: emptying a kind is not a thing to reach by leaving a path short.would_forget(K.s.a, recorded_before=t"…")— how many versions that same range would remove, removing none. It takes the same three widths, so every removal you can write has a preview you can write. It is a read, so a read-only key can ask, and a removal you have counted first is one you meant — atK.sandK, count first.assert(K.s.a, src)— record an absence — that there is deliberately no value — with no value argument.pin(K.s.a)— mark a cell (a flag you can later enumerate withlist_pinned()); pinning does not protect the cell fromforget.
Every write takes a source as its last argument: provenance is not optional. A claim always knows where it came from. This is the habit with no Python equivalent — you do not just store "final", you store that op1 said "final" — and it is what makes a store auditable rather than merely stateful.
This example revises a value, marks a cell, removes another, and records a deliberate absence:
# revise-remove-mark.dpy — supersede, pin, forget, and assert absence.
def revise_remove_mark():
remember(Widget.w1.state, "draft", "op1")
update(Widget.w1.state, "final", "op1") # revise (same source supersedes)
pin(Widget.w1.state) # mark the cell
remember(Widget.w1.note, "temp", "op1")
forget(Widget.w1.note) # drop that cell's claims
assert(Widget.w1.price, "op1") # record a deliberate absence
print("revised, marked, removed, and asserted")
Notice that update supersedes rather than overwrites: the prior value stays as history you can walk. Removal is the deliberate exception — forget reclaims every value at the path it names, and what it removes does not come back.
Reading
current(K.s.a)gives the liveactive_claim(orempty).get(K.s.a)gives the full history as aclaim_listyou can iterate.
Recording twice and reading the history shows how claims accumulate:
# history-walk.dpy — record, supersede, then walk the history.
def history_walk():
remember(Cat.felix.color, "grey", "op1")
update(Cat.felix.color, "orange", "op1") # same source supersedes
cur = current(Cat.felix.color)
match cur:
case active_claim:
print("current colour = " + str(cur.value) + " (per " + str(cur.source) + ")")
case empty:
print("no colour")
hist = get(Cat.felix.color)
print("history depth = " + str(hist.length))
Fields on a claim
An active_claim exposes typed fields: .value (the typed value), .source (who recorded it), .created_at (when, as an RFC 3339 UTC string), and .trust. A claim_list and any collection answer .length, and any value or discriminator answers .kind. Only ever read .value/.source on a claim you know is active — inside a case active_claim: arm, or right after a write. This is why reads come back as a match-able result rather than a bare value: there is no None to trip over, because empty is a case you are made to handle.
empty is one answer to two different questions
A cell you never wrote reads empty. So does a cell you DID write, whose value carries a validity window that does not cover the moment you asked about (Chapter 4 records values across time and shows how a window is written). The read cannot tell you which, and it does not try — it answers about the instant you asked about, and at that instant there is nothing.
why_not tells you which:
# out-of-force.dpy — the same empty read, explained.
def explain_empty():
reading = current(Sensor.room.temp)
match reading:
case active_claim:
print(str(reading.value))
case empty:
reason = why_not(Sensor.room.temp)
print(reason)
For a cell that was never written it says no rule concludes it. For one that is recorded but out of force it says so and names the value it holds — a fact you already have, not one to go and find. Reach for it the first time an empty answer surprises you. When a whole QUERY comes back empty rather than one cell, the store-wide version of the same question is the checkup tool on the wire (MCP §4) — it is a tool and not a language verb, so it is something you call instead of a program, not something you write inside one.
Chapter 3 — Typed values
A recorded value carries its own type, and you get that exact type back when you read it. DKE Python has nine value classes: you can write eight of them, and null you can only detect on read.
Writing typed values
The write literals are:
- string —
"orange", with the escapes\\ \" \n \t \r \xNN \uNNNN. - int —
42, and also0xDE_AD,0o17,0b1010,1_000_000. - bool —
TrueorFalse. - datetime —
datetime("2026-01-02T03:04:05Z"), an RFC 3339 UTC instant; theZis required and 1–9 fractional-second digits are allowed. - duration —
duration("PT1H30M"), an ISO 8601 span (P[nD][T[nH][nM][nS]], no calendar years or months). The lowest term may carry up to nine fractional digits, so spans are exact to the nanosecond —duration("PT0.5S"). - real —
3.5, a fractional number (a digit on each side of the point;_separators allowed,3alone stays an int). - blob —
blob("48656C6C6F"), an opaque byte string as an even count of hex digits — or the same bytes as base64,blob_b64("SGVsbG8=")(both read back as canonical hex). - scientific —
scientific("6.022e23"), a number that keeps its significant figures (a mantissa and a required exponent; the short form issci"6.022e23").scientific("6.022e23")andscientific("6.0220e23")are the same magnitude but distinct values — the trailing zero counts.
The type is chosen by the literal you write — 42 records an int, "42" records a string, and the two never collide on read:
# values-write.dpy — three cells, three different value classes.
def values_write():
remember(Widget.w1.size, 42, "op1") # int
remember(Widget.w1.ready, True, "op1") # bool
remember(Widget.w1.label, "42", "op1") # string (not the int 42)
n = current(Widget.w1.size)
match n:
case active_claim:
print("size = " + str(n.value))
case empty:
print("no size")
datetime and duration read back in a canonical form — duration("PT90M") comes back as PT1H30M, and a datetime keeps its instant:
# datetime-round-trip.dpy — a datetime value keeps its type across write/read.
def datetime_round_trip():
remember(Event.launch.at, datetime("2026-01-02T03:04:05Z"), "ops")
cur = current(Event.launch.at)
match cur.value:
case datetime as d:
print("launch at " + str(d))
case default:
print("not a datetime")
# duration-round-trip.dpy — a duration keeps its type and reads back canonical.
def duration_round_trip():
remember(Task.build.budget, duration("PT90M"), "ops")
cur = current(Task.build.budget)
match cur.value:
case duration as u:
print("budget = " + str(u)) # prints PT1H30M
case default:
print("not a duration")
real and blob round-trip the same way. A real reads back in a canonical decimal form (3.50 comes back as 3.5), and a blob reads back as canonical uppercase hex:
# real-blob-round-trip.dpy — a real and a blob keep their types across write/read.
def real_blob_round_trip():
remember(Sensor.a.reading, 3.5, "ops")
remember(Doc.d1.payload, blob("48656C6C6F"), "ops")
r = current(Sensor.a.reading)
match r.value:
case real as rv:
print("reading = " + str(rv)) # prints 3.5
case default:
print("not a real")
b = current(Doc.d1.payload)
match b.value:
case blob as bv:
print("payload = " + str(bv)) # prints 48656C6C6F
case default:
print("not a blob")
The value union
When you read a claim’s .value, its type is one of nine classes:
value = int | bool | string | datetime | duration | real | null | blob | scientific
You discriminate them with a match over .value. The eight writable classes can bind the typed value with as; the one detect-only class, null, is matched bare — it has no literal and you never write it (an absent value is asserted with the assert verb, Chapter 2), but you can still recognise it on read:
# value-classes.dpy — every value class discriminated on read.
def value_classes():
remember(Sensor.room.count, 41, "op1")
c = current(Sensor.room.count)
match c.value:
case int as n: print("next = " + str((n + 1))) # n : int, arithmetic OK
case bool as b: print("flag = " + str(b))
case string as s: print("text = " + str(s))
case datetime as d: print("time = " + str(d))
case duration as u: print("span = " + str(u))
case real as r: print("half = " + str((r / 2))) # r : real, arithmetic OK
case blob as b2: print("bytes = " + str(b2)) # b2 : blob
case scientific as s: print("sci = " + str(s)) # s : scientific
case null: print("an absent value") # detect-only, no binding
A match over .value must either name all nine classes or end with a case default:. Because the recorded value here is an int, the int arm runs — and because every arm is type-checked, case int as n: can safely do integer arithmetic on n. Numbers compute with + - * / // ** % and the ordered comparisons < > <= >=, over int and real alike, matching Python 3: / is true division (7 / 2 is 3.5), // is floor division (7 // 2 is 3, -7 // 2 is -4), % is floor modulo at either operand class (the sign follows the divisor, so -7 % 2 is 1 and -7.5 % 2 is 0.5), and ** is power (right-associative). One DKE specific is worth pinning now: 2 ** -1 is 0.5 as you would expect, but an int base with an exponent that only turns out negative at run time (2 ** n) refuses — a written literal can be typed, a computed value cannot, and the refusal names the fix. And where Python raises, DKE refuses: dividing by zero, an integer overflow, or a result out of range is a catchable refuse rather than a garbage number or an exception.
The type system at a glance
The value classes above are what you store. They are part of a larger, closed type vocabulary, with no subtyping and no user-defined types. Most you never write down; you meet them as the shapes verbs hand back. They fall into a few families:
| Family | Types | You meet them as |
|---|---|---|
| Primitives | string, int, bool |
literals, .length, comparison results |
| Value | value, datetime, duration, real, blob, scientific |
a claim’s .value union and its five named value-class types (null is a further class of the union, not a standalone type) |
| Discriminators | active_claim (with its empty case), verify_result |
results of current and verify — destructured with match |
| Finite collections | claim_list, proof_tree, diagnosis, subject_set, string_list, result_type_set |
results of get, why, why_not, subjects, and the list_* verbs — iterated with for … in |
| Handler bindings | refuse_info, engine_error_info |
what except refuse as e and except engine_error as e bind (Chapter 9) |
That is the whole vocabulary, and this tutorial deliberately does not tell you how many entries it has. The Language Reference §4.1 carries the table that is the list; a total written here would be a second copy of it, and the two would part company the first time the language grew.
Two accessors work across every family: .length gives an int for any string or collection, and .kind gives the type’s name as a string for any discriminator or a proof_tree. You meet each of these types as the tutorial goes — the collections in Chapters 5 and 11 and the discriminators throughout.
Working with strings
Strings are more than something to concatenate with +. You can read one character with s[i], take a substring by slicing with s[a:b], search with s.find(sub), count with s.count(sub), test the ends with s.startswith(prefix) / s.endswith(suffix), rewrite with s.replace(old, new), change case with s.upper() / s.lower(), and trim the ends with s.strip() / s.lstrip() / s.rstrip(). Positions count characters, and .length is the character count. Slicing and every string method are total — they never fail: an out-of-range slice bound is clamped, find returns -1 when the substring is absent, and case mapping is character-for-character (so a string keeps its length).
A common shape is cleaning up a value, then splitting it on a separator — trim it, test it, find the separator, and slice around it:
# strings-tutorial.dpy — trim, test, search, slice, and change case.
def strings_tutorial():
email = " Merlin@Langsyn.org ".strip()
at = email.find("@")
print("user = " + str(email[:at]).lower()) # everything before the @
print("host = " + str(email[at + 1:])) # everything after
print("length = " + str(email.length)) # character count
print("is org = " + str(email.endswith(".org")))
print("dots = " + str(email.count(".")))
print("has at = " + str(("@" in email))) # substring membership
print("shown = " + str(email.upper()))
To test whether one string appears inside another, use in (and not in) — the same operator Python uses: "@" in address is True when the address contains an @. It tests substring containment, so it is string-only; the in in a for loop is a different thing (the loop keyword, iterating a store-derived set).
Both bounds of a slice are optional (s[a:], s[:b], s[:]), and a slice can take a third step value — s[a:b:step] — that picks every step-th character. A negative step goes backwards, so s[::-1] reverses a string and s[::2] takes every other character. Negative positions count from the end, the same as Python: s[-1] is the last character, s[-3:] the last three, and s[:-1] everything but the last. The strip family also takes an optional set of characters to trim instead of whitespace (s.strip("*_ ")). The string methods introduced here are find, count, startswith, endswith, replace, upper, lower, and strip/lstrip/rstrip — enough for the work in this chapter. The method set is closed: there is no open namespace to extend, and the Language Reference lists every member. It is also larger than this chapter needs — the methods that bridge text and collections (split, join, partition, splitlines) arrive with lists in Chapter 14 and tuples in Chapter 15, once there is something for them to return. Until then, reach for slicing and these methods, composed with + and .length.
Chapter 4 — Recording and reading across time
The claims in Chapter 2 were all about now. But knowledge in DKE is not only current — it is dated. A write may carry a validity window: a from- and a to-datetime bracketing the span the value was true. A read may carry an as-of datetime, asking for the value the store held at that moment rather than now. Together they let one cell hold a value that changed over time, and let you read it at any instant — a bitemporal history you get for free, without bookkeeping of your own.
Two non-overlapping windows model a rate that changed, and two as-of reads select the value in force at each date. A single source may not assert two current values at one cell, so each window is recorded from its own source:
# reading-across-time.dpy — a value that changed over time, read as of a past date.
def reading_across_time():
remember(Rate.usd.pct, "3.0", "y2019", datetime("2019-01-01T00:00:00Z"), datetime("2021-01-01T00:00:00Z"))
remember(Rate.usd.pct, "5.0", "y2021", datetime("2021-01-01T00:00:00Z"), datetime("2023-01-01T00:00:00Z"))
early = current(Rate.usd.pct, datetime("2020-06-01T00:00:00Z"))
print("rate as of 2020 = " + str(early.value)) # 3.0 — inside the first window
later = current(Rate.usd.pct, datetime("2022-06-01T00:00:00Z"))
print("rate as of 2022 = " + str(later.value)) # 5.0 — inside the second
The write signature is remember(K.s.a, v, src, t"from", t"to") and the timed read is current(K.s.a, t"as-of"); both time arguments are optional — omit the window to record a value with no stated end, and omit the as-of time to read the present. get takes an as-of time the same way. A datetime is an RFC 3339 UTC instant with the required Z suffix.
The window may be in the future
Nothing about a validity window says it has to be over. A release date, a rate change agreed for next quarter, a contract that starts in January — these are facts you know now and that become true later, and a window is how you say so.
The consequence is worth being deliberate about: a fact whose window has not opened yet is not part of the present, so a read at the present will not report it. That is correct — it is not true yet — but it means the as-of time is not a convenience for historical questions. It is how you read a commitment back at all.
# scheduled-commitment.dpy — a fact recorded now, valid later.
def scheduled_commitment():
remember(Release.v2.ships, "2027-03-01", "roadmap",
datetime("2027-01-01T00:00:00Z"), datetime("2027-12-31T00:00:00Z"))
now = current(Release.v2.ships)
match now:
case active_claim:
print("ships " + str(now.value))
case empty:
print("nothing ships today") # correct — the window has not opened
then = current(Release.v2.ships, datetime("2027-06-01T00:00:00Z"))
print("as of mid-2027: " + str(then.value)) # 2027-03-01
The enumeration verbs you meet in the next chapter take the same argument, for the same reason.
Chapter 5 — Asking questions
Beyond reading a single cell, you can ask the store questions.
verify checks whether a cell holds a particular value and gives you a verify_result with a .match boolean:
# confirm-value.dpy — verify a cell against an expected value.
def confirm_value():
remember(Cat.felix.color, "orange", "op1")
v = verify(Cat.felix.color, "orange")
if v.match:
print("confirmed: colour is orange")
else:
print("mismatch: expected orange, got " + str(v.actual))
Reading with verify gets subtle when two sources disagree. On a contradicted cell — one holding co-active claims from different sources — verify asks “is this value present here?”, not “is this the settled value?”. So it reports a match for each of the competing values:
# verify-contradicted.dpy — verify a cell two sources disagree on.
def verify_contradicted():
remember(Cat.felix.color, "orange", "op1")
remember(Cat.felix.color, "grey", "op2") # a second source disagrees
orange = verify(Cat.felix.color, "orange")
grey = verify(Cat.felix.color, "grey")
print("orange matches? " + str(orange.match)) # true
print("grey matches? " + str(grey.match)) # true — both are co-active
Invoking it, both checks pass — the cell genuinely holds both values at once:
OK script verify_contradicted(): 2 claims written, 2 prints
remember Cat.felix.color = "orange"
remember Cat.felix.color = "grey"
print orange matches? True
print grey matches? True
Two different values both answering true is itself the sign that a cell is contradicted — verify never hides the disagreement by picking a winner. The two verbs that inspect disagreement directly, caveats and conflicts, appear later in this chapter.
agreement turns that into a count: how many distinct sources back a given value. It is verify’s quantitative sibling — where verify asks “is this value present?”, agreement asks “how many sources say it?”:
# count-sources.dpy — how many distinct sources back each value.
def count_sources():
remember(Cat.felix.color, "orange", "op1")
remember(Cat.felix.color, "orange", "op2") # a second source agrees
remember(Cat.felix.color, "red", "op3") # a third source disagrees
orange = agreement(Cat.felix.color, "orange")
red = agreement(Cat.felix.color, "red")
print("orange sources = " + str(orange)) # 2
print("red sources = " + str(red)) # 1
Invoking it counts the distinct sources behind each value:
OK script count_sources(): 3 claims written, 2 prints
remember Cat.felix.color = "orange"
remember Cat.felix.color = "orange"
remember Cat.felix.color = "red"
print orange sources = 2
print red sources = 1
A count is evidence, not a verdict. Two sources backing a value does not make it true, and the store does not treat those sources as independent witnesses — it simply reports how many recorded the value. Which sources to trust is your judgment; read them by name with get. (agreement counts every source, so it takes no per <source>.)
subjects finds every subject of a kind whose attribute holds a given value, and returns a subject_set you can size or iterate. It is type-discriminating — searching for the int 42 never matches the string "42":
# find-subjects.dpy — which cats are orange?
def find_subjects():
remember(Cat.felix.color, "orange", "op1")
remember(Cat.tom.color, "orange", "op1")
remember(Cat.mittens.color, "black", "op1")
orange_cats = subjects(Cat.color, "orange")
print("orange cats = " + str(orange_cats.length))
The list_* family enumerates structure — attributes of a kind, subjects, values at an attribute, and more:
# list-structure.dpy — enumerate what a kind knows.
def list_structure():
remember(Cat.felix.color, "orange", "op1")
remember(Cat.felix.age, 3, "op1")
attrs = list_attributes(Cat)
print("Cat has " + str(attrs.length) + " attribute(s)")
These list_* heads (list_attributes, list_values, list_subjects, …) are soft keywords — recognised as verbs only right before (, so the same words are free to be ordinary identifiers elsewhere. The full enumeration family walks a kind’s subjects, values, and categories:
# enumerate-store.dpy — walk the store's structure.
def enumerate_store():
remember(Cat.felix.color, "orange", "op1")
remember(Cat.tom.color, "grey", "op1")
every = list_subjects(Cat) # subject_set — every subject of the kind
subs = list_subjects(Cat.color) # subject_set — only those with a colour
vals = list_values(Cat.color) # claim_list — the values recorded there
cats = list_categories() # string_list — the kinds in the store
print("every = " + str(every.length) + ", coloured = " + str(subs.length) + ", values = " + str(vals.length) + ", kinds = " + str(cats.length))
list_subjects, list_values and list_attributes(K.s) take the same optional as-of datetime as current and get (Chapter 4), and it matters more here. A cell read you can always reach by naming the cell; an enumeration is how you find something you do not already know the name of. Without a time, they enumerate the present, so the scheduled commitment from Chapter 4 is reachable only by someone who already knows the subject, the attribute and a date inside the window — which is to say, by someone who already has the answer.
# walk-as-of.dpy — find a commitment recorded for later.
def walk_as_of():
remember(Release.v2.ships, "2027-03-01", "roadmap",
datetime("2027-01-01T00:00:00Z"), datetime("2027-12-31T00:00:00Z"))
when = datetime("2027-06-01T00:00:00Z")
here = list_subjects(Release)
print("subjects now: " + str(here.length)) # 0
subs = list_subjects(Release, when)
print("subjects then: " + str(subs.length)) # 1
attrs = list_attributes(Release.v2, when)
vals = list_values(Release.ships, when)
print("attrs = " + str(attrs.length) + ", values = " + str(vals.length))
Two of the family take no as-of time: list_categories() and the one-argument list_attributes(K). Those report the kinds and attribute names the store has recorded — its shape rather than its state — so they already cover every time, and there is nothing for an anchor to select. Note that list_attributes therefore takes a time in its K.s form and not in its K form: one reads a subject’s claims, the other a kind’s vocabulary.
Two verbs inspect disagreement. caveats(K.s) returns the cautionary claims on a subject, and conflicts() returns the claims that stand in disagreement — the co-active alternatives from different sources you met in Chapter 2:
# inspect-disagreement.dpy — caveats on a subject, conflicts across the store.
def inspect_disagreement():
remember(Cat.felix.color, "orange", "op1")
remember(Cat.felix.color, "grey", "op2") # a second source disagrees
cv = caveats(Cat.felix)
cf = conflicts()
print("caveats = " + str(cv.length) + ", conflicting claims = " + str(cf.length))
The store also keeps a small registry you can read back — the pins you have set, the result types the language can produce, and the names of the scripts you have stored:
# read-registry.dpy — enumerate pins, result types, and scripts.
def helper():
remember(Cat.felix.color, "orange", "op1")
def read_registry():
remember(Cat.felix.color, "orange", "op1")
pin(Cat.felix.color)
pins = list_pinned() # string_list — pinned cell paths
rts = list_result_types() # result_type_set — the types verbs can return
scr = list_scripts() # string_list — stored script names
print("pinned = " + str(pins.length))
print("result types = " + str(rts.length) + ", scripts = " + str(scr.length))
Summary statistics over an attribute
Beyond reading single cells, you can summarise a whole attribute — the values of one attribute across every subject of a kind. An aggregate query folds a attribute into one summary value. It is read-only: it never changes the store.
# summary-stats.dpy — read-only summary statistics over an attribute.
def summary_stats():
remember(Reading.r1.value, 10, "sensor")
remember(Reading.r2.value, 20, "sensor")
remember(Reading.r3.value, 30, "sensor")
n = count(Reading.value)
mean = avg(Reading.value)
hi = max(Reading.value)
print("readings = " + str(n) + ", mean = " + str(mean) + ", max = " + str(hi)) # 3, 20, 30
The single-attribute folds include count, sum, avg, min, max, median, variance, and stdev; two-attribute folds relate two attributes of one kind — slope(Point.x, Point.y), correlation(Point.x, Point.y). Each returns a value whose class follows the attribute (a numeric attribute yields a number), so bind it and render or match it.
Two rules matter. First, bind an aggregate to a name on its own line, then use that name — an aggregate may not be written as a bare argument to another call (n = count(Reading.value) then print("readings = " + str(n)), never print("readings = " + str(count(Reading.value)))). Second, a numeric fold refuses rather than guess: an aggregate over an attribute with no data refuses, and so does a numeric fold (sum, avg, min, max, stdev, …) over an attribute whose values are not numbers — it declines rather than treating a non-number as zero. Aggregate over numeric data you know exists, or guard the call. (count counts any class.) The fold names are soft keywords, recognised as an aggregate only right before (, so count, sum, and the rest stay ordinary identifiers elsewhere.
One thing an aggregate query does not do: answer per subject. avg(Reading.value) is the mean of every reading in the store, one number, however many sensors wrote them. For a mean per sensor — one answer each — the fold goes in a standing rule’s conclusion instead, which Chapter 8 covers under “Concluding a count”.
For reference, here is the full verb surface — each verb’s result type and where it appears in this tutorial:
| Verb | Result | Chapter |
|---|---|---|
remember / update / forget / assert / pin |
(void) | 1, 2 |
current |
active_claim |
1 |
get |
claim_list |
2 |
verify |
verify_result |
5 |
agreement |
int |
5 |
subjects / list_subjects |
subject_set |
5 |
list_attributes / list_categories / list_pinned / list_scripts |
string_list |
5 |
list_values / caveats / conflicts / dependents |
claim_list |
5 |
list_result_types |
result_type_set |
5 |
why |
proof_tree |
12 |
Chapter 6 — Procedures
Every program so far has been a single def. A source file may declare several, and within one file this is how you structure a program: procedures that compile in order and share their work through the store. (Splitting work across files is what modules are for — Chapter 17.)
Procedures compile in declaration order, and a later one may call an earlier one — this is compile-before-call. A call is expanded inline at compile time, so there is no recursion and calls never form a cycle: the same termination guarantee that governs loops governs calls.
In-language, you call a procedure as a plain call — name(args), with no call keyword. From a run, the same call reaches a compiled module’s script once you have imported it: import <module> then <module>.<name>(...) (MCP §4.2):
# compose-scripts.dpy — two procedures; the second calls the first.
def seed_colour():
remember(Cat.felix.color, "orange", "op1")
def audit_colour():
seed_colour() # calls the earlier declaration
cur = current(Cat.felix.color)
match cur:
case active_claim:
print("audited colour = " + str(cur.value))
case empty:
print("no colour to audit")
When a file declares more than one procedure, running it executes the last one (here audit_colour), which is the natural entry point. Here is the departure a Python programmer feels most: procedures share their work through the store, not through return values. seed_colour records a claim; audit_colour reads it back. There is no value-returning procedure call — data flows through the store. (Class methods, in Chapter 7, are the one exception: they do return values.)
Procedures may also take typed parameters (def name(room: string):), supplied positionally when called. A parameter may be any value type the engine stores — the primitives string, int, bool and the value-class scalars datetime, duration, real, blob — plus the read-result types (claim_list, active_claim, …) for calls between procedures. A value-class scalar is passed as its canonical text: "2026-01-02T03:04:05Z" for a datetime, "3.5" for a real, "PT1H30M" for a duration, hex for a blob. (null is absence and is never a parameter type.)
A parameter or return type may also be a collection — list<int>, dict<string, int>, set<string>, tuple<int, string>, nested as deep as you like — so a collection can be handed to a helper and handed back:
def total(xs: list<int>) -> int:
return sum(xs)
def doubled(xs: list<int>) -> list<int>:
return [x * 2 for x in xs]
total([1, 2, 3]) is 6, doubled([4, 5]) is [8, 10]. (These pass the list between procedures; a collection argument is not written on the wire — such helpers are called from other DKE Python code.)
Chapter 7 — Classes
A class groups typed fields and the methods that operate on them. Think of it as a convenient, Python-shaped way to organise the same stored facts you have worked with all along: the cells simply get names grouped by class. Nothing new happens underneath — a field read still yields a claim, and a field write still records a value together with its source.
A class declaration takes no marker — just class Name: with typed fields and def methods. A void method (def m(self, …):) is called as a statement; a value method (def m(self, …) -> T: ending in return) is called in an expression:
# classes.dpy — a class with a typed field, a void method, and a value
# method; a def binds an instance and calls both.
class Sensor:
temp: string
def record(self, v: string, src: string):
remember(self.temp, v, src) # field write
def label(self) -> string:
return "sensor" # value method
def demo():
s = Sensor("room-a") # instance binding
s.record("21", "op1") # void method call
name = s.label() # value method call
reading = s.temp # field read
match reading:
case active_claim:
print(name + " = " + str(reading.value) + " per " + str(reading.source))
case empty:
print(name + ": no reading")
Note the field write remember(self.temp, v, src) — a value and its source, exactly like remember. Provenance does not disappear because you wrapped the cell in a class. A never-written field reads back as empty.
You can dispatch on an instance’s class with a class match. It is resolved from the instance’s declared class at compile time, and must cover that class or provide case default::
# class-match.dpy — dispatch on an instance's class.
class Sensor:
temp: string
class Actuator:
state: string
def configure():
s = Sensor("room-a")
match s:
case Sensor:
remember(s.temp, "21", "op1")
print("configured a sensor")
case Actuator:
remember(s.state, "on", "op1")
print("configured an actuator")
Inheritance — one class extends another
A class can extend another, inheriting its fields and methods. Write the base class in parentheses after the name. A subclass gets everything the base has, adds its own, may override a method with its own version, and can reach the base’s version with super():
# inheritance.dpy — a Dog is an Animal with more: it inherits Animal's fields
# and methods, adds its own, overrides one, and calls the base with super.
class Animal:
sound: string
def speak(self):
remember(self.sound, "generic", "op1")
def describe(self) -> string:
return "an animal"
class Dog(Animal):
breed: string
def describe(self) -> string: # override — Dog's own version
return "a dog"
def speak(self):
super().speak() # run Animal's speak first...
remember(self.breed, "collie", "op1") # ...then extend it
class Cat(Animal):
def describe(self) -> string:
return "a cat"
def label(a: Animal): # takes any Animal — subclasses too
print(a.describe()) # runs the actual class's describe
def demo():
animals = [Dog("d1"), Cat("c1")]
for a in animals:
label(a) # "a dog", then "a cat" — polymorphism
d = Dog("d2")
if isinstance(d, Animal): # a Dog is an Animal
d.speak()
match d: # class match across the hierarchy
case Dog: print("it is a dog")
case Cat: print("it is a cat")
case default: print("unknown")
The reason inheritance matters is label: written once, for an Animal, it works for a Dog and a Cat alike — each runs its own describe. A base-typed parameter (or list element) accepts any subclass, and the call resolves to the instance’s actual class. That is polymorphism. Two companions go with it: isinstance(x, C) asks whether x is a C or a subclass of one — a Dog is an Animal, a plain Animal is not a Dog — and a class match works across a hierarchy, where case Dog: matches a Dog or any of its subclasses. A class extends at most one base.
A class field can also be computed — derived and kept current by the engine rather than written by you. That is the first form of the engine’s reasoning, and it opens the next chapter.
Chapter 8 — The engine reasons
This is the chapter with no Python analogue. Until now you have recorded facts and read them back. Now you declare facts the engine derives and maintains for you — you state a relationship once, and the engine keeps the conclusion current as its inputs change, on its own, forever after. It comes in three escalating forms: a field derived within one class, a named rule that ranges over the whole store, and a hypothesis that reasons without committing anything.
Computed fields — derivation within a class
A class field may be computed: declared with an initializer the engine derives from the class’s other fields and keeps current as they change. You write only the inputs; the derived field is maintained automatically and reads back with a derived provenance in place of a source:
# computed-fields.dpy — fields the engine derives and maintains.
class Invoice:
rate: int
hours: int
total: int = rate * hours # a computed value
large: bool = total > 1000 # a computed flag, built on the one above
def computed_fields():
remember(Invoice.i1.rate, 150, "billing")
remember(Invoice.i1.hours, 8, "billing")
t = current(Invoice.i1.total)
print("total = " + str(t.value)) # 1200 (derived: rate × hours)
big = current(Invoice.i1.large)
print("large = " + str(big.value)) # true (derived: total > 1000)
An int field is an arithmetic expression (+ - * %) over earlier fields; a real field may also use /, and its % may take real operands; a bool field is one ordered comparison of an earlier field. Every reference points at a field declared earlier, so a class’s computed fields never form a cycle — and writing an input re-derives every field built on it. You will see the derivation itself in Chapter 12, where why over a computed field lists the inputs it was built from.
Standing rules — derivation across the store
A computed field derives one field of one class from that class’s own fields. A standing rule is the same idea made standalone and named: it states a condition over the stored data and a conclusion to record whenever that condition holds. Once defined, the conclusion always reflects the current data — it is in force exactly while its condition holds, so a derived fact is never stale.
A rule is a def marked with the @rule decorator, named like any script, then a for clause naming the subjects it ranges over, an if clause of one or more premises joined by and, and an indented conclusion. The two premises to start with are a comparison of a field against a number (o.total > 1000) and an absence — absent(o.shipment), which holds when the subject has no value recorded for that field. A premise may also test a field for equality (o.lic == "MIT"), or relate two subjects to each other, which the next two sections are about:
# standing-rule.dpy — a named rule the engine keeps current on its own.
@rule
def needs_review():
for o in Order:
if o.total > 1000 and absent(o.shipment):
o.review = True
def standing_rule():
remember(Order.o1.total, 1500, "sales")
r = current(Order.o1.review)
print("review before shipment = " + str(r.value)) # true — the rule fired
remember(Order.o1.shipment, "sent", "logistics")
after = current(Order.o1.review)
match after:
case active_claim:
print("review after shipment = " + str(after.value))
case empty:
print("review withdrawn after shipment") # this arm runs
Running it shows the rule’s conclusion in force while the order is unshipped, then no longer in force once a shipment is recorded (the absent(o.shipment) premise stops holding):
OK script standing_rule(): 2 claims written, 2 prints
remember Order.o1.total = 1500
print review before shipment = True
remember Order.o1.shipment = "sent"
print review withdrawn after shipment
The derived review field reads back like any other field, with a derived provenance; its value always reflects the rules currently in force. When you need to know for certain whether a claim was concluded rather than recorded, read its trust — asserted or derived — rather than its source: a source is a string the writer chose and could be the word “derived” itself, while trust is the engine’s own account and cannot be written. The for/if here look like the control flow of Chapter 9, but read them declaratively: a rule does not loop, it holds. Two things keep a rule usable, each checked when you define it: a rule must name which subjects it applies to — every name it binds needs a premise that is not an absence test — and its outcome must settle, so a rule set that would cancel itself out is rejected. A rule ranges over finitely many subjects and its premises are tested, not iterated, so the termination guarantee of Chapter 13 still holds.
Once you have defined a few rules, list rules at the wire surface names the ones you have — names only, so keep your own source for what each rule does.
A class does not fence a rule in. If you met class in Chapter 7 you may expect it to constrain what a rule can conclude, and it does not: a rule may conclude about a kind no class declares, into a field its class does not declare, and with a value whose type is not the declared one — a rule concluding s.mean = avg(r.v) over whole-number readings records 1.5 into a field declared int, and nothing objects.
That is not an inconsistency with Chapter 7, where reading an undeclared field off an instance is an error. The two are the same rule from opposite sides. When you write s = Sensor(room) you have an instance, and a class is a closed schema for instances — that is what makes s.typo catchable. When a rule writes for s in Sensor, it has a path, and paths are the store’s open surface: anyone may record anything anywhere, which is the property the whole store rests on. A class is a schema where you construct one, and a vocabulary where you reason.
The practical reading: declare a field when you want the instance surface to check your spelling, and do not expect the declaration to validate what a rule derives. If a derived value must be of a certain shape, the rule that produces it is where that is decided.
Rules over more than one subject — relating them
needs_review looks at one order at a time. Many questions are about a relationship instead — this function calls that one, this order belongs to that customer — and a rule can bind more than one subject and relate them. Separate the bindings with commas, and relate them with a premise whose right side is another of those names rather than a value:
@rule
def taint_propagates():
for f in Fn, c in Call, g in Fn:
if f.tainted == True and c.caller == f and c.callee == g:
g.tainted = True
Read it plainly: whenever a tainted function f is the caller of some call c, and c’s callee is g, then g is tainted too. c.caller == f reads the field as a reference — it holds when caller names the subject f — so two of the three names are found rather than compared: c and g are reached through f. That is what lets a single rule follow a chain. The bindings are commas rather than nested for blocks on purpose: a rule does not walk the store, it states a condition over it, and nesting would suggest an order that is not there.
Because the conclusion writes the same field the first premise reads, the rule applies to its own results — the taint reaches everything the call graph connects, not a fixed number of steps out.
Concluding a count — a fold grouped by its subject
Every conclusion so far has recorded a value you wrote into the rule: g.tainted = True. A conclusion can instead fold the rows the rule ranged over. That is how a rule counts.
# regular-customers.dpy — count each customer's orders, then flag on the count.
class Customer:
name: string
order_count: int
regular: bool
class Order:
customer: string
@rule
def count_orders():
for c in Customer, o in Order:
if o.customer == c:
c.order_count = count(o)
@rule
def regulars():
for c in Customer:
if c.order_count > 3:
c.regular = True
def show():
remember(Customer.c1.name, "Ada", "sales")
remember(Customer.c2.name, "Bo", "sales")
remember(Order.o1.customer, "c1", "sales")
remember(Order.o2.customer, "c1", "sales")
remember(Order.o3.customer, "c1", "sales")
remember(Order.o4.customer, "c1", "sales")
remember(Order.o5.customer, "c2", "sales")
ada = current(Customer.c1.order_count)
bo = current(Customer.c2.order_count)
print("Ada orders = " + str(ada.value))
print("Bo orders = " + str(bo.value))
OK script show(): 7 claims written, 2 prints
remember Customer.c1.name = "Ada"
remember Customer.c2.name = "Bo"
remember Order.o1.customer = "c1"
remember Order.o2.customer = "c1"
remember Order.o3.customer = "c1"
remember Order.o4.customer = "c1"
remember Order.o5.customer = "c2"
print Ada orders = 4
print Bo orders = 1
Read it plainly. The for clause ranges over customer–order pairs, the premise keeps the pairs that belong together, and the conclusion names c — so the fold is taken over the other name, once for each customer. Ada gets 4 and Bo gets 1; nobody gets the store’s total.
That per-subject answer is the whole difference from the aggregate query of Chapter 5. count(Order.customer) folds one attribute across every subject of the kind and yields a single number — how many orders exist, which here is five. count(o) in a conclusion yields one number per subject — how many are this customer’s. Reaching for the Chapter 5 form to ask “how many orders does each customer have” answers five, for everybody, and looks right until you check the second customer.
Two shapes, told apart by what is folded. count(o) folds the rows themselves — a bare row name. sum(o.amount) folds one field of those rows, and naming a field makes its presence a premise, so an order with no amount contributes nothing to the sum while still counting as a row. The single-operand folds you already know — count, sum, avg, min, max, median, stdev, argmax, distinct — all work here. The ones that take a second argument (percentile, slope) do not: a conclusion has nowhere to put it.
A fold ranges over matches, not over subjects. Chapter 5 taught you that several sources can back one value — that is what agreement counts. When they do, the location holds that value more than once in force, and the row matches once per value. An order two sources agree on is therefore folded twice.
# corroborated-count.dpy — one fold counts rows, the other counts orders.
class Customer:
name: string
rows: int
orders: int
class Order:
customer: string
@rule
def count_rows():
for c in Customer, o in Order:
if o.customer == c:
c.rows = count(o)
@rule
def count_orders():
for c in Customer, o in Order:
if o.customer == c:
c.orders = distinct(o)
def show():
remember(Customer.c1.name, "Ada", "sales")
remember(Order.o1.customer, "c1", "sales")
remember(Order.o2.customer, "c1", "sales")
remember(Order.o2.customer, "c1", "audit")
remember(Order.o3.customer, "c1", "sales")
remember(Order.o4.customer, "c1", "sales")
rows = current(Customer.c1.rows)
orders = current(Customer.c1.orders)
print("rows = " + str(rows.value))
print("orders = " + str(orders.value))
OK script show(): 6 claims written, 2 prints
remember Customer.c1.name = "Ada"
remember Order.o1.customer = "c1"
remember Order.o2.customer = "c1"
remember Order.o2.customer = "c1"
remember Order.o3.customer = "c1"
remember Order.o4.customer = "c1"
print rows = 5
print orders = 4
Ada has four orders and the store holds five records of them, because o2’s customer was written by sales and again by audit — the same remember line twice in the transcript. Both numbers are right, for different questions: count(o) answers how many records, distinct(o) answers how many orders.
So regular-customers.dpy above answers 4 only because nothing in its store is corroborated. Reach for distinct(o) when the question is about the things themselves and the store may hold more than one source per fact; reach for count(o) when the question really is about the records.
A derived field is an ordinary premise, which is why “count, then flag” is two rules rather than one. regulars reads c.order_count exactly as it would read a field somebody recorded by hand, and so can any later rule or query. Each rule states one thing, and the engine works out that the second needs the first.
Facts about a pair
g.tainted is a fact about one subject. A fact about a pair — a reaches b — is written with the same three-segment path you use everywhere else, except that its last two segments are bound names:
# reaches.dpy — a rule that reads what it writes, so one step becomes every step.
class Fn:
cyclic: bool
class Call:
caller: string
callee: string
class Reach:
placeholder: bool
@rule
def reaches_direct():
for c in Call, x in Fn, y in Fn:
if c.caller == x and c.callee == y:
Reach.x.y = True
@rule
def reaches_further():
for c in Call, x in Fn, y in Fn, z in Fn:
if Reach.x.y == True and c.caller == y and c.callee == z:
Reach.x.z = True
def reaches():
remember(Call.c1.caller, "main", "src")
remember(Call.c1.callee, "parse", "src")
remember(Call.c2.caller, "parse", "src")
remember(Call.c2.callee, "lex", "src")
one = current(Reach.main.parse)
two = current(Reach.main.lex)
print("main reaches parse = " + str(one.value))
print("main reaches lex = " + str(two.value))
OK script reaches(): 4 claims written, 2 prints
remember Call.c1.caller = "main"
remember Call.c1.callee = "parse"
remember Call.c2.caller = "parse"
remember Call.c2.callee = "lex"
print main reaches parse = True
print main reaches lex = True
Nothing recorded that main reaches lex. The calls say only main → parse and parse → lex; the second rule reads a reach already derived, joins it to a further edge, and records the longer one — so it applies to its own conclusions until nothing new follows. Reading a relation with a bound name in the field position asks for every cell of that subject, which is why Reach.x.y in a premise finds each y that x reaches instead of needing one supplied.
A name used twice in one cell agrees with itself, so asking which subjects are in a cycle needs nothing further — a subject that reaches itself is in one:
@rule
def in_cycle():
for x in Fn:
if Reach.x.x == True:
x.cyclic = True
A relation is as large as the pairs it holds. A rule like reaches_further records a fact for every pair one subject reaches, and each of those is a stored claim you can read, question and withdraw like any other. That is the right shape for a module import graph, a service dependency map, or a workflow of a few hundred subjects. For a graph of thousands, ask the narrower question — the number of steps you actually need — rather than deriving every pair.
Hypotheses — reasoning without committing
A hypothesis reads what a value would be under supposed facts, changing nothing. suppose(<field>, <value>, …, <field>) sets each named field to the value beside it, lets your rules and computed fields run as if those were true, reads the last field, and then discards the suppositions — the store is left exactly as it was:
# what-if.dpy — read a value under a supposed fact, committing nothing.
@rule
def needs_review():
for o in Order:
if o.total > 1000:
o.review = True
def what_if():
would = suppose(Order.o1.total, 2000, Order.o1.review)
print("if total were 2000, review would be = " + str(would)) # true
after = current(Order.o1.review)
match after:
case active_claim:
print("persisted?! " + str(after.value))
case empty:
print("nothing persisted") # this arm runs
OK script what_if(): 2 prints
print if total were 2000, review would be = True
print nothing persisted
The result is a value — bind it and read it, or concatenate it into a print (it renders true here). If the read field has no value under the supposition, suppose produces no value — a catchable refuse (guard it with try / except, Chapter 9, or read a field a rule is known to derive). Because nothing it touches persists, a hypothesis is safe for preview, comparison, and what-if exploration.
Supposing more than one fact
Real scenarios rarely turn on a single fact. A rule that needs two conditions at once cannot be probed by supposing one of them — so give as many <field>, <value> pairs as the question takes. The field to read is always the last argument, alone:
# what-if-two.dpy — a rule that needs two facts, asked about both at once.
@rule
def needs_review():
for o in Order:
if o.total > 1000 and o.shipment == "sent":
o.review = True
def what_if_two():
remember(Order.o1.total, 500, "sales")
remember(Order.o1.shipment, "held", "ops")
both = suppose(Order.o1.total, 2000,
Order.o1.shipment, "sent",
Order.o1.review)
print("under both = " + str(both)) # True
after = current(Order.o1.review)
match after:
case active_claim:
print("persisted?! " + str(after.value))
case empty:
print("still nothing persisted") # this arm runs
Supposing only the total here would answer nothing at all: the order still has not shipped, so the rule does not fire and there is no value to read. Both facts have to be true at the same time, which is exactly what the scenario says.
Two things worth knowing. Suppositions apply left to right, so if two of them name the same field the later one stands — the same rule an ordinary sequence of writes follows. And a hypothesis takes at most 16; that is a cost bound, not a limit on what you can ask, and going over it is a compile error that says the number. A scenario written by hand does not come close.
suppose_absent takes as many fields as you like the same way, and its read is also the last argument — suppose_absent(a, b, c) supposes away a and b and reads c. One hypothesis supposes facts into place or away, though, never both.
Asking what you would lose
There is a second hypothesis, and it is the one to reach for before you delete anything. suppose_absent(<field>, <field>) asks what the second field would say if the first field’s fact were not there:
r = suppose_absent(Order.o1.total, Order.o1.review)
match r:
case active_claim:
print("review would still say", r.value)
case empty:
print("review would disappear")
forget (Chapter 7) cannot be undone, and dependents only tells you what points at a fact — not what your answers become without it. This tells you that, while the fact is still there to keep.
Note what comes back: an active claim that may be empty, matched exactly like current. That differs from suppose on purpose. suppose asks what a value would be, so having no answer is unusual and arrives as a refuse you catch. suppose_absent asks what would survive — and “nothing” is usually exactly what you were checking for, so you read it rather than catch it.
Chapter 9 — Control flow as constraints
You have been using DKE Python’s control flow all along — match since Chapter 1, if since Chapter 5, for … in inside rules in Chapter 8. It is the control flow you already know from Python: if/else, for … in, match, and try/except. What earns a chapter is not what these constructs do — you have that — but what DKE deliberately takes away.
There is no while, no recursion, and no unbounded loop; for … in ranges only over a finite collection the store hands back, never an open-ended counter. That is not a limitation to work around — it is the mechanism by which every DKE Python program is guaranteed to terminate, bought by refusing the shapes that could loop forever. The rest of this chapter is the specific guardrails.
if / else
The condition can be any value — DKE Python uses Python’s truthiness. if xs: is true when the list is non-empty, if s: when the string is non-empty, if n: when the number is non-zero; False, 0, 0.0, "", the empty collections, and None are falsy. You saw a plain boolean condition in if v.match: in Chapter 5, but you can branch on emptiness directly too. Two more Python idioms follow from this: and / or return an operand (not a coerced bool), so name or "anonymous" is name when it is non-empty and "anonymous" otherwise, and x > 0 and label is label when x > 0; and not x gives the boolean opposite of any value’s truthiness.
for … in
You may only iterate a finite source — a claim_list, string_list, subject_set, result_type_set, or proof_tree; a constructed list / set / dict; or a string, which binds each character in turn (a one-character string), by Unicode character. The source is snapshotted when the loop begins:
# iterate-history.dpy — walk every claim in a cell's history.
def iterate_history():
remember(Cat.felix.color, "grey", "op1")
remember(Cat.felix.color, "orange", "op2") # co-active alternative
hist = get(Cat.felix.color)
print("walking " + str(hist.length) + " claim(s)")
for c in hist:
print(" recorded by " + str(c.source))
match
match dispatches three ways — over a read result (active_claim vs empty, as in Chapter 1), over a .value’s class (Chapter 3), and over an instance’s class (Chapter 7). It never dispatches on structural shape, as Python’s does. Each match must be exhaustive, or end in case default:.
try / except
A recoverable failure is a refuse; a fault from the service layer is an engine_error. You catch them in that fixed order, binding the details:
# guard-try.dpy — catch a refuse and an engine error.
def guard_try():
try:
remember(Cat.felix.color, "orange", "op1")
print("write succeeded")
except refuse as r:
print("refused: " + str(r.reason))
except engine_error as e:
print("engine error: " + str(e.reason))
Both handlers are optional, and so is each handler’s as <name>: write a bare except refuse: when the body does not need the refuse details.
A closing note: comparisons chain as in Python — a < b < c means (a < b) and (b < c), each adjacent pair compared — so range checks read naturally.
Chapter 10 — Transactions
Ordinary invocation is not transactional: if a later write fails, earlier writes still stand. When you need all-or-nothing, open a branch:
# try-paint.dpy — an experimental write, committed only if it holds.
def try_paint():
with branch("experiment"):
try:
remember(Cat.felix.color, "tigret", "audit")
after = current(Cat.felix.color)
if after.value == "tigret":
commit
else:
rollback
except refuse as r:
print("paint refused: " + str(r.reason))
rollback
A branch runs its suite and commits on normal completion; an uncaught failure rolls it back and re-raises. Inside a branch, commit and rollback finish it explicitly. Branches do not nest, and only the store is rolled back — your variable bindings are never undone. This is the one place the store behaves transactionally: a rolled-back branch leaves no trace, where an ordinary sequence of writes leaves each write as history.
Chapter 11 — Tracing what a claim caused
Given a claim, you can ask what other claims the engine derived from it — the forward direction of provenance, complementing why in the next chapter:
# track-changes.dpy — read a claim, then see what was derived from it.
def track_changes():
remember(Cat.felix.color, "orange", "op1")
update(Cat.felix.color, "grey", "op1") # a later edit supersedes it
cur = current(Cat.felix.color) # the current claim at this cell
deps = dependents(cur) # claims derived from this claim
print("dependents = " + str(deps.length))
hist = get(Cat.felix.color) # every claim at the cell, in order
print("history depth = " + str(hist.length))
dependents(<claim>)→claim_list— the claims the engine derived from a claim (read the claim fromget/currentfirst). A base fact with no rules firing on it has none.get(K.s.a)→claim_list— every claim ever recorded at the cell, so a supersession chain is readable as history without any bookmark to compare against.
A note on where undo lives. The language has no named restore point, no cursor rewind, and no cell-scoped diff. Durable undo is a store snapshot, taken and restored from your account dashboard rather than from the language — and it is the stronger tool for the job: a snapshot survives a
forget, where rewinding a cursor could not honour the guarantee that removal is permanent, and it asks nothing of you up front, where a cell-scoped diff would need you to already know which cell had changed in order to ask. Restoring replaces the store with the snapshot, and keeps an automatic backup of what it replaced, so the undo is itself undoable.
Chapter 12 — Provenance with why
Every claim knows where it came from, and why shows you the whole derivation that produced a value. It returns a proof_tree — a finite, iterable collection of the claims that participated, with .length, indexing, for … in, and a .root (the claim you asked about):
# show-why.dpy — inspect the provenance of a recorded value.
def show_why():
remember(Cat.felix.color, "orange", "op1")
pt = why(Cat.felix.color)
print("proof has " + str(pt.length) + " claim(s)")
r = pt.root
print("root recorded per " + str(r.source))
for c in pt:
print(" participating claim per " + str(c.source))
A proof_tree (like any collection) must not be handed straight to print — read a claim’s string field, or build a string with +, as above.
When the value you ask about is derived — a computed field or a fact a standing rule recorded (Chapter 8, where the engine derives facts) — why shows the claims it was built from, not just the value itself. This is where the engine’s reasoning becomes auditable: a derived fact carries the inputs that justify it:
# why-derived.dpy — why over a derived value lists the claims it was built from.
class Order:
price: int
qty: int
total: int = price * qty
def why_derived():
remember(Order.o1.price, 300, "sales")
remember(Order.o1.qty, 5, "sales")
pt = why(Order.o1.total) # total is derived: price × qty
print("proof has " + str(pt.length) + " claim(s)") # 3 — the value and its two inputs
root = pt.root
print("derived value = " + str(root.value)) # 1500
Why the answer changed
A derivation rests on claims, and claims carry validity windows (Chapter 4). So a conclusion can hold at one moment and not another, without anybody rewriting a rule — the premise simply moved out of force, or a different one moved in.
why takes the same as-of datetime current and get take, and it answers with the derivation as it stood then:
# why-changed.dpy — the same question, asked at two moments.
@rule
def needs_review():
for o in Order:
if o.total >= 1000:
o.review = True
def why_changed():
remember(Order.o1.total, 900, "sales", # first half of 2024
datetime("2024-01-01T00:00:00Z"),
datetime("2024-05-31T00:00:00Z"))
remember(Order.o1.total, 1500, "sales", # second half
datetime("2024-06-01T00:00:00Z"),
datetime("2024-12-31T00:00:00Z"))
early = why(Order.o1.review, datetime("2024-03-01T00:00:00Z"))
late = why(Order.o1.review, datetime("2024-09-01T00:00:00Z"))
print("in March: " + str(early)) # <empty> — the rule had not fired
print("in September: " + str(late)) # a proof_tree
for c in late:
print(" rested on " + str(c.value) + " per " + str(c.source))
In March the order totalled 900 and the rule did not fire, so there is no derivation to show. In September it totalled 1500 and the rule concluded, so the proof carries the premise that made it true. The difference between the two answers is the account of what changed.
Note what the March call returns. There being no derivation is not a zero-length proof — it is empty, the same absence sentinel a never-written current gives you (Chapter 4). So reach for .length or for … in only on an answer you know is a proof; a str() tells the two apart, and match is the thorough way.
An empty proof is a finding, not a failure: it means no conclusion was derivable at that instant. That is a different thing from a conclusion that is derivable and false, which is the next section’s question.
Omit the datetime and you get the derivation as of now, exactly as before — the anchor is an addition, so every why you have already written reads the same.
When the answer is not there
why needs a claim to explain. The harder moment is the other one: you ask for a conclusion and get nothing back, and the store cannot tell you anything — because there is no claim to point at.
why_not answers that. It names the condition that blocked the conclusion, at the cell you can act on. What makes it worth having is a distinction you cannot otherwise make: an empty read looks exactly the same whether a condition was never recorded, or was recorded and fails its test. Those need opposite fixes.
# why-not.dpy — the two empties that need opposite fixes.
@rule
def device_is_online():
for d in Device:
if d.power > 0 and d.checks > 0:
d.online = True
def show_why_not():
remember(Device.b.power, 5, "s1") # b: checks never recorded
remember(Device.c.power, 5, "s1")
remember(Device.c.checks, 0, "s1") # c: recorded, and it fails > 0
remember(Device.w.power, 5, "s1")
remember(Device.w.checks, 2, "s1", # w: recorded, passes the test,
datetime("2024-01-01T00:00:00Z"), # and its window has closed
datetime("2024-12-31T00:00:00Z"))
why_b = why_not(Device.b.online)
why_c = why_not(Device.c.online)
why_w = why_not(Device.w.online)
print(why_b) # rule `device_is_online` needs Device[b].checks > 0,
# and nothing is recorded there
print(why_c) # rule `device_is_online` needs Device[c].checks > 0,
# and Device[c].checks is "0"
print(why_w) # rule `device_is_online` needs Device[w].checks > 0,
# and Device[w].checks is recorded as "2" but not in force
# at the time asked about
w is the one worth pausing on. Its checks is recorded and its value passes the rule. What it lacks is force at the moment you asked: the window you gave the write closed before then. The move is a wider window or an as-of inside the one you have — not another write, which is what “nothing is recorded there” would have sent you to do.
The same thing is true of the cell you ask about directly. why_not(Device.w.checks) — the windowed cell itself, not the conclusion it feeds — tells you it is recorded and out of force, rather than leaving you to infer it from an empty get. You do not need a rule in the picture to get that answer.
Two more answers are worth recognising. If no standing rule concludes the cell at all, you are told exactly that — your model has no path to the conclusion, which is a different problem from an unmet condition. And if the sources at a condition disagree, you are told that too — when the disagreement is the reason nothing concluded. A rule reads every value standing at a location and derives from each one that satisfies its condition, so a disagreement where one side would satisfy the rule gives you a conclusion from that side; you hear about the disagreement when no side satisfies it. Either way the store decides nothing: conflicts() still names the location, and why on a conclusion shows the claim it came from.
what_needs asks the same situation forward. Where why_not says what is blocking, what_needs says what would unblock it — walking through conditions your own rules can derive and stopping at the facts only you can supply.
need_b = what_needs(Device.b.online)
print(need_b) # via rule `device_is_online`:
# have Device[b].power
# need Device[b].checks > 0 (record this)
need_w = what_needs(Device.w.online)
print(need_w) # via rule `device_is_online`:
# have Device[w].power
# need Device[w].checks > 0 (recorded, but not in force
# at the time asked about)
The w line is the same distinction reaching the forward question. A list of things to do is only useful if every line is a thing you have not done, so a condition you already recorded says so rather than asking for it again.
Both are reads: asking why something is missing never changes whether it is. Both return a diagnosis — print it for the whole answer, or iterate it a line at a time.
Chapter 13 — Errors and guarantees
Four things can go wrong, each with a distinct class:
parse_error— the source is not well-formed (a syntax slip). Caught at compile time.type_error— the source parses but is not well-typed (a non-boolifcondition, a chained comparison, anasbinding on a detect-only value class). Also caught at compile time.refuse— a runtime request the service declines (for examplerollbackoutside a branch, an aggregate over an attribute with no data, or a hypothesis whose supposed read has no value). Refuses are control flow — you catch them withexcept refuse. Where Python would raise, DKE hands you a refuse.engine_error— a fault from the service layer, caught withexcept engine_error.
Because a refuse is ordinary control flow, you can turn a declined request into a printed line rather than a crash:
# catch-refuse.dpy — a refuse is control flow, not a crash.
def catch_refuse():
try:
rollback # refuses: there is no branch here
print("this line is skipped")
except refuse as r:
print("caught a refuse: " + str(r.reason))
Two guarantees hold for every DKE Python program, and they are the deepest departure from Python — not a feature you invoke but a property you cannot opt out of:
- Termination. With no recursion and only bounded iteration over finite collections, every program finishes. There is no infinite loop to write.
- Determinism. The same program over the same store produces the same result shape every time.
Python gives you the freedom to loop forever and to depend on run-to-run variation; DKE trades both away on purpose, and gets provable termination and reproducibility in return.
Chapter 14 — Lists and comprehensions
Everything so far has read collections out of the store — a get gives a claim_list, subjects a subject_set. DKE Python also lets you build your own lists on the client side, with the syntax you already know from Python, and transform them without ever touching the service.
A list literal is written […]. You can index it, slice it, ask its .length, and test membership with in, exactly as in Python. A comprehension ([expr for x in xs if cond]) filters or maps one list into another. And two string methods bridge text and lists: split cuts a string into a list<string>, and join stitches one back into a string.
# lists-tutorial.dpy — build a list, read it, filter with a comprehension,
# and bridge to and from text with split and join.
def lists_tutorial():
scores = [90, 55, 72, 40, 88]
print("all = " + str(scores))
print("how many = " + str(scores.length))
print("top = " + str(scores[0]))
print("worst = " + str(scores[-1]))
passing = [s for s in scores if s >= 60]
print("passing = " + str(passing))
csv = "ada,grace,alan"
names = csv.split(",")
print("names = " + str(names))
shouted = [n.upper() for n in names]
print("shouted = " + str(", ".join(shouted)))
Run it with run (MCP §4.2):
A list renders as its element count and a bracketed preview; a comprehension keeps only the elements its if admits; split and join are inverses across a separator:
OK script lists_tutorial(): 7 prints
print all = [90, 55, 72, 40, 88]
print how many = 5
print top = 90
print worst = 88
print passing = [90, 72, 88]
print names = ['ada', 'grace', 'alan']
print shouted = ADA, GRACE, ALAN
These lists are immutable and bounded: there is no append, and every comprehension ranges over a finite source, so — like every other DKE Python construct — a program over lists is guaranteed to terminate (Chapter 13). A constructed list never round-trips the service; it is a pure client-side value, like a string.
Chapter 15 — Tuples, dicts, and sets
Chapter 14 built lists. DKE Python gives you three more collections you already know from Python — tuples, dicts, and sets — and, like a list, each is a pure client-side value: bounded, immutable, and never sent to the service. You build them, read them, and transform them entirely on your side.
Tuples — a fixed group of mixed-type values
A tuple (a, b, c) is a fixed-length group whose positions may each hold a different type. A comma is what makes it a tuple: (x) just groups an expression, (x,) is a one-element tuple, and () is empty. Because the positions can differ in type, you index a tuple with a constant number — t[0], t[1], t[-1] — and each read has a definite type. A tuple has a .length and compares with ==, but it is not something you write for … in (its positions have no single type).
# tuples-demo.dpy — build tuples, index them, and compare them.
def tuples_demo():
point = (3, 4)
print("point = " + str(point))
print("x = " + str(point[0]))
print("y = " + str(point[1]))
print("dims = " + str(point.length))
print("last = " + str(point[-1]))
mixed = (1, "ada", True)
print("mixed = " + str(mixed))
print("name = " + str(mixed[1]))
nested = ((1, 2), 3)
print("nested = " + str(nested))
single = (7,)
print("single = " + str(single))
print("same = " + str((3, 4) == point))
Run it with run (MCP §4.2):
A tuple renders as (…), a one-element tuple keeps its trailing comma, and two tuples of the same shape compare equal position by position:
OK script tuples_demo(): 10 prints
print point = (3, 4)
print x = 3
print y = 4
print dims = 2
print last = 4
print mixed = (1, 'ada', True)
print name = ada
print nested = ((1, 2), 3)
print single = (7,)
print same = True
Dicts — a mapping kept in insertion order
A dict {key: value, …} maps keys to values. Keys are scalars (a string, an int, a datetime, …) and are kept in the order you wrote them — DKE Python dicts are insertion-ordered, matching modern Python. Look a value up with d[k] (or safely, with a fallback, d.get(k, default) — which returns default when the key is absent instead of refusing; a default is required, since DKE Python has no None), test a key with k in d, count entries with .length, walk the keys with for k in d, and pull the parts out with .keys(), .values(), and .items() (which hands back a list of (key, value) tuples). Writing a key twice keeps its first position but takes the last value. The empty dict is {}. Two dicts compare order-independently with == / != — equal when they hold the same key→value mapping, regardless of the order you wrote them in.
# dicts-demo.dpy — build a dict, read it by key, and project its parts.
def dicts_demo():
ages = {"ana": 30, "bo": 25, "cy": 41}
print("ages = " + str(ages))
print("count = " + str(ages.length))
print("ana = " + str(ages["ana"]))
print("has bo = " + str(("bo" in ages)))
print("has zz = " + str(("zz" in ages)))
print("same = " + str((ages == {"cy": 41, "bo": 25, "ana": 30})))
print("names = " + str(ages.keys()))
print("years = " + str(ages.values()))
print("pairs = " + str(ages.items()))
for who in ages:
print("key = " + str(who))
dup = {"x": 1, "x": 9}
print("dup = " + str(dup))
Run it with run (MCP §4.2):
The dict renders in insertion order; .keys() / .values() / .items() are lists; and the duplicate key "x" keeps its place but takes the later value 9:
OK script dicts_demo(): 13 prints
print ages = {'ana': 30, 'bo': 25, 'cy': 41}
print count = 3
print ana = 30
print has bo = True
print has zz = False
print same = True
print names = ['ana', 'bo', 'cy']
print years = [30, 25, 41]
print pairs = [('ana', 30), ('bo', 25), ('cy', 41)]
print key = ana
print key = bo
print key = cy
print dup = {'x': 9}
Looking up a missing key is a refuse, not a silent default — catch it with a try (Chapter 13) or guard with k in d first.
Sets — unique elements, presented sorted
A set {a, b, c} (no colons — a colon makes it a dict) holds unique elements: duplicates are dropped on the way in. DKE Python presents a set in a deterministic sorted order, so a run is reproducible (Python leaves set order unspecified; sorting it is a defined refinement). Test membership with x in s, count with .length, walk it with for x in s, and combine sets with .union, .intersection, .difference, and .symmetric_difference. Because {} is the empty dict, the empty set has no literal.
# sets-demo.dpy — dedupe, test membership, and combine sets.
def sets_demo():
tags = {"red", "green", "red", "blue"}
print("tags = " + str(tags))
print("size = " + str(tags.length))
print("has red = " + str(("red" in tags)))
warm = {"red", "orange"}
print("union = " + str(tags.union(warm)))
print("shared = " + str(tags.intersection(warm)))
print("only tags = " + str(tags.difference(warm)))
print("either only = " + str(tags.symmetric_difference(warm)))
nums = {3, 1, 2, 3, 1}
print("nums = " + str(nums))
for n in nums:
print("n = " + str(n))
Run it with run (MCP §4.2):
The four repeated "red" and duplicate 3/1 collapse to one each, and every set — literal, combined, or walked — comes out sorted:
OK script sets_demo(): 11 prints
print tags = {'blue', 'green', 'red'}
print size = 3
print has red = True
print union = {'blue', 'green', 'orange', 'red'}
print shared = {'red'}
print only tags = {'blue', 'green'}
print either only = {'blue', 'green', 'orange'}
print nums = {1, 2, 3}
print n = 1
print n = 2
print n = 3
Dict and set comprehensions
Chapter 14’s comprehension built a list. The same shape, in braces, builds a set or a dict. {expr for x in xs} is a set comprehension (deduped and sorted); {key: value for x in xs} is a dict comprehension (insertion-ordered). Both may carry an if filter, and both range over a finite source, so — like every DKE Python construct — they terminate.
# comprehend-demo.dpy — build a dict and a set by bounded iteration.
def comprehend_demo():
nums = [1, 2, 3, 4, 5]
squares = {n: n * n for n in nums}
print("squares = " + str(squares))
evens = {n for n in nums if n % 2 == 0}
print("evens = " + str(evens))
parity = {n % 2 for n in nums}
print("parity = " + str(parity))
words = "ada,grace,alan".split(",")
lengths = {w: w.length for w in words}
print("lengths = " + str(lengths))
Run it with run (MCP §4.2):
The dict comprehension keeps insertion order; the set comprehension dedupes n % 2 down to {0, 1} and sorts:
OK script comprehend_demo(): 4 prints
print squares = {1: 1, 2: 4, 3: 9, 4: 16, 5: 25}
print evens = {2, 4}
print parity = {0, 1}
print lengths = {'ada': 3, 'grace': 5, 'alan': 4}
Pairing collections: zip, enumerate, and partition
Two builtins turn collections into lists of tuples. zip(a, b) pairs elements position by position (stopping at the shorter input); enumerate(xs) numbers each element from 0. Both hand back an ordinary list, so you can index it or walk it with for pair in …: pair[0] … pair[1]. And on the string side, partition and rpartition split a string once — at the first or last separator — into a three-part tuple, while splitlines breaks a string into a list of its lines.
Three more reduce a collection to one value: sum(xs) adds numeric elements (0 for an empty list), and min(xs) / max(xs) return the smallest / largest — min/max also take the values directly, min(3, 7, 2). These read an in-memory list; they are distinct from the same-named store aggregates sum(K.col) / max(K.col) (Chapter 5), which fold a whole store column — the argument tells them apart (a bare Kind.column path folds the store; anything else reduces the list in hand). min/max of an empty list refuses, like Python’s ValueError.
A few more round out the set. sorted(xs) returns a new list in order (sorted(xs, reverse=True) descending); any(xs) / all(xs) fold truthiness to a bool (all([]) is True); bool(x) gives any value’s truthiness; and tuple(xs) makes an immutable, runtime-length tuple from a list — you can index it (t[i]) and size it (len(t)), but not unpack it (its length isn’t known until it runs).
# pairing-demo.dpy — pair lists, number them, and split strings.
def pairing_demo():
names = ["ada", "grace", "alan"]
scores = [90, 85, 70]
paired = zip(names, scores)
print("paired = " + str(paired))
print("first pair = " + str(paired[0]))
ranked = enumerate(names)
print("ranked = " + str(ranked))
for pair in zip(names, scores):
print(pair[0] + " scored " + str(pair[1]))
parts = "user@example.com".partition("@")
print("parts = " + str(parts))
print("local = " + str(parts[0]))
print("domain = " + str(parts[2]))
tail = "a.b.c".rpartition(".")
print("tail = " + str(tail))
lines = "one\ntwo\nthree".splitlines()
print("lines = " + str(lines))
Run it with run (MCP §4.2):
zip and enumerate produce lists of tuples you index straight into; partition splits at the first @ and rpartition at the last .:
OK script pairing_demo(): 11 prints
print paired = [('ada', 90), ('grace', 85), ('alan', 70)]
print first pair = ('ada', 90)
print ranked = [(0, 'ada'), (1, 'grace'), (2, 'alan')]
print ada scored 90
print grace scored 85
print alan scored 70
print parts = ('user', '@', 'example.com')
print local = user
print domain = example.com
print tail = ('a.b', '.', 'c')
print lines = ['one', 'two', 'three']
Every collection here — tuple, dict, and set — is immutable and bounded, exactly like a list: you build a new one rather than mutating in place, and each stays a pure client-side value that never round-trips the service.
Chapter 16 — Loading many facts at once
Everything so far wrote one claim per statement. That is the right shape for a handful of facts and the wrong one for ten thousand: a program that says remember ten thousand times is ten thousand lines to generate, to send, and to read back.
You do not need a different verb. Put the claims in data and write the remember once. A path segment that names a variable in scope resolves to that variable’s value, so the subject can come from the row:
# survey-load.dpy — many claims from one written `remember`.
def survey_load():
rows = [("kitchen", 21), ("hallway", 18), ("cellar", 9)]
for r in rows:
room = r[0]
remember(Sensor.room.temp, r[1], "survey")
Three claims, one written remember. The rows can be as long as you like — the loop does not grow.
Only the subject needs a name of its own (room = r[0]), because a path segment has to be an identifier. The value and the source take the row’s fields directly, which is what lets each claim carry its own provenance:
# survey-sources.dpy — each claim carries the source its own row named.
def survey_sources():
rows = [("attic", 14, "survey-a"), ("porch", 11, "survey-b")]
for r in rows:
room = r[0]
remember(Sensor.room.temp, r[1], r[2])
Each cell now records the source that particular reading came from, exactly as if you had written the two remember calls out by hand.
When a row is bad
A subject arriving from data is data, and data has bad rows. A value that is not a name — one holding a space, or a ., or leading with a digit — is refused, and the refusal names the value, not just the line:
that subject is not a usable name: `room` held "back porch" — a name starts
with a letter or `_` and continues with letters, digits, `_` or `-`, so it
cannot hold a space or a `.`
That matters here more than anywhere else. The loop is one line, so the line number tells you nothing about which of ten thousand rows was wrong; the value is the only thing that identifies it.
Because it is a refuse and not an error, you can take it per row — skip what will not load, keep what will, and report the rest:
# survey-skip.dpy — skip the rows that will not load, keep the rest.
def survey_skip():
rows = [("larder", 6), ("back porch", 19), ("study", 20)]
kept = 0
skipped = 0
for r in rows:
room = r[0]
try:
remember(Sensor.room.temp, r[1], "survey")
kept = kept + 1
except refuse as e:
skipped = skipped + 1
print(e.reason)
print("kept " + str(kept) + ", skipped " + str(skipped))
If you would rather have all-or-nothing, wrap the loop in a transaction (Chapter 10) and a bad row takes the whole load back with it:
# survey-atomic.dpy — a bad row takes the whole load back with it.
def survey_atomic():
rows = [("landing", 17), ("scullery", 12)]
with branch("survey"):
for r in rows:
room = r[0]
remember(Sensor.room.temp, r[1], "survey")
What it costs
The same as writing the claims out one at a time. Each claim you ask for is one operation whether it came from a statement you typed or a row in a list, so the compact form is cheaper to write and to read back, never cheaper to buy. Choose the shape that says what you mean.
Chapter 17 — Modules
Everything you have compiled so far was a module, whether or not you thought of it that way. A .dpy file is a module: it is the unit you submit, the unit that gets stored under a name, and the unit another program can import.
A module’s top level is its body, read top to bottom. It may hold declarations (def, class, a @rule-marked def), ordinary statements, and — this is the part with no Python equivalent — data declarations: writes that sit at module scope rather than inside a procedure.
# rates.dpy — a module that ships a fact and a procedure that reads it.
remember(Rate.vat.pct, 25, "finance-act-2026")
def show_vat():
r = current(Rate.vat.pct)
match r:
case active_claim:
print("vat = " + str(r.value) + "%")
case empty:
print("no rate recorded")
Compiling that stores the def and performs the write, because compiling a module runs its body. The write is not a side effect to be careful of; it is how a module ships the facts its procedures need.
Importing
import <module> loads a module already stored under that name — it runs the module’s body once and brings its names into scope:
import rates
def quote():
rates.show_vat()
Because loading runs the body, anything the imported module prints or writes belongs to your run: it joins your transcript at the point of the import, and counts in your verdict. An import cycle — a module that imports itself, directly or through another — is rejected when the module is loaded.
Re-submitting is reconciliation, not duplication
This is the place DKE departs hardest from Python’s import. A Python module body runs once per process. A DKE module is durable store state, so compiling it again — or importing it again — re-runs the body, and the re-run reconciles:
- a data declaration whose value changed updates the stored cell;
- a data declaration whose value is unchanged leaves the cell alone — an identical write is not recorded again;
- a
classor rule install is a no-op when nothing changed.
So editing rates.dpy and compiling it again moves exactly the cells whose values you changed, and nothing else. Re-submitting is safe to repeat, which is what lets you treat a module as the current statement of what is true.
Namespaces, and who wins
A module is a namespace. A kind or script it declares is addressed under the module name — code.Function, code.impact_of — so two modules may each declare a Function without ever touching each other’s cells.
Importing brings the imported module’s kinds into scope unqualified, so after import code a bare Call means code.Call. When that could be ambiguous, the rule is: your own declaration wins; failing that, the one imported module that declares the name; and if two imported modules declare it and you declare none, the program is refused rather than resolved by import order. The qualified form always works and is always unambiguous, so it is the fix.
The same qualified name calls an imported module’s scripts:
import code
def review(fn: string):
who = code.callers_of(fn)
print(str(who.length) + " caller(s)")
Modules somebody else wrote
We publish reasoning modules — ordinary .dpy files with the classes, rules and queries for one domain already written. You download the file and compile it into your store, once, and import it from then on. A published name like code is a default, never a reserved word: your store holds whatever you compiled under that name.
Once compiled it is yours, without qualification. You can read every line before you run it, remove a rule you do not want, or edit the file and compile again. Nothing puts a removed rule back — your store holds what you put in it.
The published modules have their own tutorial and reference — Reasoning Modules — starting with code, which reasons about call graphs, complexity and dependencies.
Chapter 18 — Where to go next
The language teaches itself, too. Any source can carry a single option-verb line to print a lesson — bare --help for an index, or --help <topic> for a specific area:
--help types
From here:
- The Language Reference is the exhaustive companion, organised by construct: every verb, type, operator, statement and class rule with a worked example.
- The Wire & Tool Contract is the transport: the tool surface, the response envelope, the error model, and billing.
- The MCP Tutorial walks the connection itself — key, endpoint, first
compile, firstrun. - Reasoning Modules are modules we publish, which you compile into your store instead of writing the same declarations again.
Everything you record is a claim, at a cell, with a source and a type you get back unchanged; the engine will reason over those claims on your behalf; and why will always show you how a value came to be. That — not the Python-shaped syntax — is the whole of DKE Python.