# DKE Python — Tutorial **Version:** alpha · API 1. This tutorial teaches DKE Python by example; the [Language Reference](/ref/dke/python/) is the exhaustive entry-per-construct companion, and the [Wire & Tool Contract](/ref/dke/mcp/) specifies the transport that carries a program to the service. **This document as Markdown:** [dke-python-tutorial.md](/tutorial/dke/python/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 in :`. 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: ```dpy 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: ```dpy # 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: ```dpy # 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: ```dpy # 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* named `Greeting.hello.text` (a kind `Greeting`, a subject `hello`, an attribute `text`), 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 an `active_claim` or `empty`, and a `match` must handle both. DKE's `match` dispatches on a result's *kind* — not on Python's structural patterns — but the `case` syntax is the one you know. Inside the `active_claim` arm, `cur.value` and `cur.source` are 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 "" ``` 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 `compile`d — 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, use `update`. - `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, and `forget(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 a `read-write-delete` key. - `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 an `at_or_before` spelling for the boundary. There is no `after` form 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, )` trims every cell of a subject and `forget(K, )` 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 bare `forget(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 — at `K.s` and `K`, 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 with `list_pinned()`); pinning does not protect the cell from `forget`. 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: ```dpy # 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 live `active_claim` (or `empty`). - `get(K.s.a)` gives the full **history** as a `claim_list` you can iterate. Recording twice and reading the history shows how claims accumulate: ```dpy # 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: ```dpy # 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 also `0xDE_AD`, `0o17`, `0b1010`, `1_000_000`. - **bool** — `True` or `False`. - **datetime** — `datetime("2026-01-02T03:04:05Z")`, an RFC 3339 UTC instant; the `Z` is 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, `3` alone 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 is `sci"6.022e23"`). `scientific("6.022e23")` and `scientific("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: ```dpy # 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: ```dpy # 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") ``` ```dpy # 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: ```dpy # 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: ```dpy # 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: ```dpy # 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](/ref/dke/python/) 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: ```dpy # 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. ```dpy # 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: ```dpy # 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: ```dpy # 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?"*: ```dpy # 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 `.) `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"`: ```dpy # 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: ```dpy # 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: ```dpy # 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. ```dpy # 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: ```dpy # 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: ```dpy # 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. ```dpy # 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 ` then `.(...)` (MCP §4.2): ```dpy # 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`, `dict`, `set`, `tuple`, nested as deep as you like — so a collection can be handed to a helper and handed back: ```dpy def total(xs: list) -> int: return sum(xs) def doubled(xs: list) -> list: 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: ```dpy # 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:`: ```dpy # 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()`: ```dpy # 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: ```dpy # 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: ```dpy # 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. ```dpy # 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. ```dpy # 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: ```dpy # 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(, , …, )` 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: ```dpy # 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 `, ` pairs as the question takes. The field to read is always the last argument, alone: ```dpy # 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(, )` 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: ```dpy # 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: ```dpy # 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 `: 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**: ```dpy # 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: ```dpy # 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_list` — the claims the engine derived from a claim (read the claim from `get`/`current` first). 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): ```dpy # 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: ```dpy # 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**: ```dpy # 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)) # — 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. ```dpy # 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. ```dpy 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-`bool` `if` condition, a chained comparison, an `as` binding on a detect-only value class). Also caught at compile time. - **`refuse`** — a runtime request the service declines (for example `rollback` outside 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 with `except refuse`. Where Python would raise, DKE hands you a refuse. - **`engine_error`** — a fault from the service layer, caught with `except engine_error`. Because a refuse is ordinary control flow, you can turn a declined request into a printed line rather than a crash: ```dpy # 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`, 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. ```dpy # 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 ` loads a module already stored under that name — it runs the module's body once and brings its names into scope: ```dpy 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 `class` or 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: ```dpy 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](/tutorial/dke/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 ` for a specific area: ```dpy --help types ``` From here: - The [Language Reference](/ref/dke/python/) is the exhaustive companion, organised by construct: every verb, type, operator, statement and class rule with a worked example. - The [Wire & Tool Contract](/ref/dke/mcp/) is the transport: the tool surface, the response envelope, the error model, and billing. - The [MCP Tutorial](/tutorial/dke/mcp/) walks the connection itself — key, endpoint, first `compile`, first `run`. - [Reasoning Modules](/tutorial/dke/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.