DKE Library — Tutorial

Version: 1.0.5, changed in rev 171 · alpha · API 1.5 · rev 171
Status: Tutorial for the DKE Library.
Audience: Customers putting a published reasoning module to work on their own facts.
This document as Markdown: dke-library-tutorial.md — the same text this page is rendered from, for readers and tools that would rather have the source than the page.

The DKE Library is the code we publish for you to install and edit: reasoning modules, which supply the reasoning; dkepy, the Python package that writes your facts; and dkekey, the tool that issues a store key on your own machine. This tutorial teaches what a reasoning module is, how to put one to work on your own facts — by hand, or with dkepy when a program produces them — and where its answers stop being trustworthy. The Library Reference is the lookup — kinds, fields, derived fields and queries, module by module, then the public names of dkepy. It assumes you have read enough of the Python Tutorial to know what a claim is.


Chapter 1 — Two steps, and both of them are yours

Reasoning about anything takes two steps.

  1. Work out what is true, and record it. You write claims to your store one at a time, with a write verb — update if you mean to run the producer again, which you usually do (Chapter 3).
  2. Write the DKE Python that reasons over those claims — the classes, the standing rules, the queries.

Analysing a codebase is exactly that: read your source, record what you found, then compile the code that answers questions about it.

A reasoning module is the second step, already written. For some domains we have written that code for you. You download the file — code is at dke.langsyn.com/source/code/latest/code.dpy — compile it into your store once, and import it from then on:

import code

That is the whole of what a reasoning module is. Nothing about it is privileged: it is an ordinary .dpy file, the same kind you could have written, and one you write yourself behaves identically. You can read every line of it before you compile it, and you can edit it — it is yours once you have it.

The first step stays yours. Nothing here knows anything about your subject matter until you record it — importing code does not scan your repository, because it has no access to your repository. It installs the vocabulary and the reasoning; you supply the facts.


Chapter 2 — What you get when you import one

import code resolves against your store, and the only modules a store has are the ones you compiled into it. So the module you get is the one you put there — whether you downloaded it from us or wrote it yourself.

What arrives is up to five kinds of declaration:

  • Classes — the kinds of thing the domain has, and the fields they carry. code brings Function, Class, Call, UnresolvedCall, Rationale, Import, Dependency and Counterpart.
  • Derived fields — values the engine works out from the ones you wrote, and keeps current as those change. You never write a derived field.
  • Standing rules — conclusions that hold exactly while their condition holds over your data, and withdraw themselves when it stops holding.
  • Standing constraints — conditions your data is expected to meet. checkup names every subject that breaks one, and a write that breaks one is not refused. Of the published modules agenda, quantum and pqc have some (Chapters 11 to 13).
  • Queries — defs you call by name, which answer over whatever facts you have loaded.

Names are scoped to the module, so code.Function and finance.Function are different kinds that never read each other’s data. Two modules can each have a Function without either knowing about the other.


Chapter 3 — Loading facts

code reasons over data in your store. Data gets there the way all data does — one claim at a time — and code neither knows nor cares what produced it. There is no import step and no format to convert to. Analyse your source however you like, then write what you found:

import code

update(code.Function.parse.name, "parse", "my-analysis")
update(code.Function.parse.module, "compiler", "my-analysis")
update(code.Function.parse.lines, 42, "my-analysis")
update(code.Function.parse.branches, 8, "my-analysis")
update(code.Function.parse.loops, 4, "my-analysis")

update(code.Call.c1.caller, "main", "my-analysis")
update(code.Call.c1.callee, "parse", "my-analysis")
update(code.Function.parse.called, True, "my-analysis")

That last line looks redundant next to the Call above it, and it is not. uncalled() reads a field of the function, not the Call kind, so the answer has to be recorded on the callee to be a fact about it. Write both for every edge you record: the Call is what callers_of and impact_of walk, and called is what stops uncalled() from returning a function the store plainly shows being called.

A third party can do the analysis, or a script you write, or your CI. The store sees claims either way, and the provenance you pass — the third argument — records where they came from.

Why update and not remember. Both write a claim, and neither is a fallback for the other. remember asserts something new; update says revise my earlier reading, and establishes a value on an empty cell so it also works the first time. A producer you mean to run again — a script, a CI step, anything pointed at a codebase that changes — means the second, so it should ask for it by name. remember is permissive and a re-run does succeed either way; what you lose by leaning on that is a reader of your program being able to see which you meant. Chapter 8 says submitting a module again reconciles it; this is the same idea one layer down, for the facts.

Supersession is per SOURCE. update replaces what this source said and leaves every other source’s claim standing, so re-running your producer never quietly settles a disagreement someone else recorded.

When a machine writes the facts

Writing the calls by hand is right for a handful of facts, and for learning what the cells are. When a program produces them — a reader over your source tree, a CI step, anything you point at data that changes — there is a package that builds the program for you: dkepy (distribution dkepy, import name dke), published at https://dke.langsyn.com/source/dkepy/.

from dke import FactProgram

prog = FactProgram("code", source="my-analysis")
prog.open()
prog.lines.append(prog.put("Function.parse.lines", "42"))
print(prog.render())

It emits the same program you would have written, and the output is text you can read before you run it. What it saves you is the three things that go wrong quietly when a machine writes facts: a subject that moves between runs, so a re-run records a second set beside the first instead of revising it; the write verb, which it always emits as update; and a value interpolated into the program without escaping, where one quote in your data ends the string early and changes what the REST of the program says.

It also carries readers for this module’s own domain, dke.ingest, which read source code and emit code facts — both a working producer and the worked example.

If you write a reader of your own, its subjects will usually come from qualified names like app.parse, which is not a subject as written. sanitize("app.parse") reduces one to a subject, app_parse. That loses something — app.parse and app_parse reduce to the same subject — so hand names out through an Ids instead: ids.get(name) answers the same subject for the same name for as long as you keep the instance, and when two names reduce to one subject, the second is given a short suffix of its own rather than sharing the first one’s cells. Which of the two gets the suffix depends on which you ask for first, so ask in the same order on every run — sorted, say — and a re-run lands on the same subjects. Ids and sanitize import from the package root, beside FactProgram.

Sending the program is a separate, deliberate step. Nothing that produces facts opens a socket — not FactProgram, not Ids, not the readers — so producing facts needs no key, and you can run a reader over your source without granting it anything. The one module that talks to the network is dke.client, which nothing that produces facts imports; you have it only by naming it: from dke.client import Client, then Client.from_env().run(prog.render()). from_env reads your key from DKE_API_KEY, and raises an error when it is not set. run submits the program to your store, as the run tool of the MCP Reference does, and returns a result whose ok says whether the work succeeded. A program that fails to compile comes back as a result, not an exception; call raise_for_status() on it when you would rather have one. A call that does not complete raises an error that says how it clears, and every one of them is a DkeError: TransportError when the request never reached the service, and ProtocolError when the service refused it — PaymentRequired, which clears by paying, and RateLimited, which clears by waiting, are both of that kind.

The one mistake that silently breaks everything

code.Call.caller and code.Call.callee hold the subject you recorded a Function under — not its display name.

Above, the Function is recorded under the subject parse, so the Call records "parse". If you wrote a display name there instead, the queries would still run and return answers: uncalled() compares those fields against Function subjects directly, so every function would look uncalled, with nothing in the output to tell you why. Getting this right is worth more than any other single detail on this page.

The same care applies to code.Function.test, which names a test that reaches the function. The untested rule reads its absence, so write it only where a test really does reach — otherwise the rule falls silent everywhere instead of where it should be silent.


Chapter 4 — Asking

Once facts are in, the derived fields are already current — you do not run anything to refresh them:

import code

def review(fn: string):
    who = code.callers_of(fn)
    print(str(who.length) + " caller(s)")
    for r in code.impact_of(fn):
        print("  reaches " + r)

impact_of answers “what breaks if this changes” by following callers, and theirs, to three hops. callees_of(fn) looks the other way, at what fn calls. uncalled() reports functions with no recorded caller. critical() reports the complicated and unreached — where a defect is likeliest to survive. The full list is in the Library Reference.

Those all start from a function you already suspect. hubs() is the one that tells you where to look first — the functions most depended on, most first:

import code

def where_to_look():
    for h in code.hubs():
        print(h)

It needs one thing from your producer: callers on each Function, counting the distinct functions that call it. Record callees too if you want the other direction.

import code

update(code.Function.parse.callers, 12, "my-analysis")
update(code.Function.parse.callees, 3, "my-analysis")

Write it for every function including zero — unlike called, this one is read by comparison rather than by absence, so a fan-in of zero is a fact worth having. Record none of it and nothing breaks; hubs() simply stays empty.

One more number is worth recording on a function: returns_ignored, how many of its calls throw the result away — neither bound, nor tested, nor handed on.

import code

update(code.Function.parse.returns_ignored, 2, "my-analysis")

code.discarded_results() then answers parse. It lists every function that throws a result away at least once, which is where a failure a callee reports can go unseen by the caller.

Derived conclusions carry a derived provenance rather than a source, so you can always tell a conclusion from something you were told.

Imports and dependencies

code also reasons about what your code imports and what your project declares. An Import records one import: importer is the module doing it, imported the module as written, and root its top-level package — requests for import requests.adapters. A Dependency records one package your project depends on: its name, its version when an exact one is declared, pinned, and direct for the ones your project declares itself.

import code

update(code.Import.i1.importer, "app.main", "my-analysis")
update(code.Import.i1.imported, "requests", "my-analysis")
update(code.Import.i1.root, "requests", "my-analysis")

update(code.Dependency.requests.name, "requests", "my-analysis")
update(code.Dependency.requests.direct, True, "my-analysis")
update(code.Dependency.requests.pinned, False, "my-analysis")
update(code.Dependency.rich.name, "rich", "my-analysis")
update(code.Dependency.rich.version, "13.7", "my-analysis")
update(code.Dependency.rich.direct, True, "my-analysis")
update(code.Dependency.rich.pinned, True, "my-analysis")

Then ask three questions at once:

import code

def dependency_check():
    for m in code.importers_of("requests"):
        print("imports requests: " + m)
    for d in code.unused_dependencies():
        print("declared, never imported: " + d)
    for d in code.unpinned_dependencies():
        print("not pinned: " + d)

That prints imports requests: app.main, declared, never imported: rich and not pinned: requests. importers_of matches imported exactly as written, and unused_dependencies() compares each dependency’s name with the root of every import, so import requests.adapters still counts as using requests.

Two fields are read by value, so write them deliberately. Leave direct unwritten on a dependency your project did not declare — one that another package brought in — and unused_dependencies() does not report it. Write pinned for every dependency, False included: unpinned_dependencies() looks for False, and a dependency with no pinned is never reported. A dependency with no name is passed over rather than failing the question, and that holds for every query here: a half-written subject costs you that subject and nothing else.

Two functions that have to agree

An encoder and its decoder, a writer and its reader, two functions that ought to check their input alike — nothing in a call graph says two functions are halves of one contract. Only you know that, so code has you state it. A Counterpart names the pair, says in relation what is being counted, and gives the count each side handles:

import code

update(code.Counterpart.users.name, "create/update", "review")
update(code.Counterpart.users.left, "create_user", "review")
update(code.Counterpart.users.right, "update_user", "review")
update(code.Counterpart.users.relation, "input checks", "review")
update(code.Counterpart.users.left_covers, 4, "review")
update(code.Counterpart.users.right_covers, 3, "review")

code.asymmetries() then answers create/update [input checks] left stricter by 1: update_user accepts input that create_user refuses. The difference is kept current — record a new count for either side and the answer follows, with no audit to re-run. The two counts are comparable with each other and with nothing else, since each pair counts something of its own.


Chapter 5 — What the answers can and cannot mean

This chapter matters more than the query list, because the failure it describes is quiet.

A call graph derived from source is a lower bound. Python decides calls as it runs, so anything reading source sees the calls the source makes plain and no others — a call through a variable, a handler table or getattr is invisible to it. Every edge you record that way is real; edges are missing.

That matters most for uncalled(). A function reached only dynamically will appear on it. Read it as candidates to look at, never a list to delete.

You can measure how loose the bound is. Wherever your producer sees a call and cannot work out what it reaches, record it:

import code

update(code.UnresolvedCall.u1.caller, "app.dispatch", "ingest")
update(code.UnresolvedCall.u1.site, "app.py:11", "ingest")
update(code.UnresolvedCall.u1.text, "handlers[name]", "ingest")

Then dispatch_sites() lists them, and you read it beside uncalled():

import code

def audit():
    dead = code.uncalled()
    sites = code.dispatch_sites()
    print(str(dead.length) + " uncalled, " + str(sites.length) + " dispatch site(s)")
    for s in sites:
        print("  " + s)

Two dispatch sites and two hundred are very different reasons to believe the same uncalled() list. Nothing on it is a defect on its own — a plugin table, a callback and a decorator all work this way — it is where reading stopped telling you what runs.

If your producer records none of these, nothing changes: dispatch_sites() returns nothing and every other answer is what it was.

untested inherits the same bound, because which functions your tests reach is worked out over the same graph — and reaching a function is not executing it, since a branch may never be taken.

And structure cannot tell deliberate from overlooked. Two functions can look identical to every query on this page — same complexity, neither reached by a test — while one of them has a comment saying exactly why it is shaped that way and why testing it is not worth the trouble, and the other says nothing. critical() returns both. That is not a defect in critical(); it is that everything it reads is structure, and the difference is not structural.

So record the reasons too. Most codebases already write them down:

import code

update(code.Rationale.w1.subject, "app.gnarly", "ingest")
update(code.Rationale.w1.marker, "WHY", "ingest")
update(code.Rationale.w1.text, "the arms mirror the wire states", "ingest")
update(code.Rationale.w1.site, "app.py:40", "ingest")
update(code.Function.gnarly.explained, True, "ingest")

Then rationale_for reads them back, and unexplained() is critical() with everything already accounted for taken out:

import code

def triage():
    for f in code.unexplained():
        print(f)
        for r in code.rationale_for(f):
            print("  " + r)

A short unexplained() under a long critical() is a good result — it means the risk is known and written down rather than absent.

Which comments count is yours to decide, the same way the counting rule in Chapter 6 is, and the danger runs the same direction. A convention wide enough to sweep ordinary comments makes every line in your tree a fact and every function explained, which silences the rule instead of firing it. Pick something an author had to type on purpose. Ours records WHY: and NOTE: at the start of a comment, uppercase, and nothing else.

explained is read by its absence, like test. Write it only where a reason really is recorded.

There is no dependency age, and that is the schema being honest rather than an omission. Python’s package metadata carries no release date, so an age could only come from asking an index — which means sending your dependency list to a third party. A field nothing can honestly fill is worse than no field, because it reads as a capability.

unused_dependencies() matches by name. A package whose import name is not its package name — Pillow is imported as PIL — looks unused however much of it you import. Read the list the way you read uncalled(): names to check, not names to remove.


Chapter 6 — Comparable numbers

code.Function.hotspot fires at a complexity above 10. A fixed threshold over a number you compute only means something if we agree on how it is computed — so the Library Reference states the counting rule (§2.3), and it is worth following even if you dislike the specific choices.

The rule is: one, plus one for each decision the function makes. else, default and a bare catch-all add nothing, because they are where control lands when none of the decisions above them fired.

The list of what counts is closed, and deliberately excludes some constructs with a fair claim to being a decision — assert is the clearest. In some languages it is a macro or a compiled-out call that a reader working from source cannot reliably see, and a rule some producers can follow and others cannot is the problem the rule exists to solve. A number everyone computes the same way is worth more than a number that is arguably more faithful.

If your store holds more than one language, record language on each function. branches is whatever your analysis counted, two producers can both be right on different scales, and complexity compares them against one threshold either way. Recording it also lets you ask about one language at a time: code.functions_in("python") lists every function recorded as python, and code.hotspots_in("python") only the hotspots among them.


Chapter 7 — The module in your store is yours

Compiling code.dpy into your store gives you the module as we publish it — every class, every rule, every query. From that moment it is an ordinary module in your store, and everything you can do to a module you can do to this one.

Its rules are removable one at a time. forget_rule on one of them removes it, and nothing puts it back: your store holds what you compiled into it, and compiling is something you do. If a rule does not suit your codebase, remove it — or better, edit the line in your copy of the file and compile again, so the file and the store agree.

What you run is what you can read. The version we publish is the version you downloaded; the file is the whole of it; and if you have changed it, you know, because you changed it.

Check the version before you assume you are current. The Library Reference states the version each module ships at, and the download URL carries it — https://dke.langsyn.com/source/code/1.12.3/code.dpy is that exact release for as long as it is the one we publish, while https://dke.langsyn.com/source/code/latest/code.dpy is whatever is current. Publishing a new version replaces the old one at its URL rather than keeping both, so link latest when a link has to keep working; the file you downloaded is yours regardless, and nothing reaches into your store. A version never changes without its content changing, so if the version you downloaded is the version we publish, your copy is the current one.


Chapter 8 — Writing your own

Nothing distinguishes yours from ours. Compile a file under a name and it behaves exactly like one of ours:

class Ledger:
    balance: int
    limit: int
    over: bool = balance > limit

def exposed() -> list<string>:
    out = []
    accounts = list_subjects(Ledger.balance)
    for s in accounts:
        c = current(Ledger.s.over)
        match c:
            case active_claim:
                out = out + [s]
            case empty:
                pass
    return out

Your modules are private to your store.

Submitting a module again reconciles it. A field whose value changed is updated, one that did not is left alone, and an edited rule replaces the earlier version of itself rather than joining it. So editing and re-submitting is the ordinary way to change a module, and doing it twice costs nothing the second time.

One thing to know when a module you import changes: a call is resolved when the calling module is submitted, and the callee’s body at that moment is the body your call runs. If a module you depend on is updated afterwards, submit yours again to move onto the new version.


Chapter 9 — A second module: identity

Records that describe one thing turn up in every system that has more than one source: a customer in the CRM and again in billing, a supplier under two spellings. identity reasons about which of your records name the same thing, without merging any of them. Version 1.0.2, at dke.langsyn.com/source/identity/latest/identity.dpy.

You record the records, and what your sources say about them. A Same statement says two records name the same thing, and its a and b hold their names; the statement’s own name is yours to choose:

import identity

update(identity.Record.alice.name, "Alice Smith", "crm")
update(identity.Record.asmith.name, "A. Smith", "billing")
update(identity.Record.ann.name, "Ann Smith", "support")

update(identity.Same.m1.a, "alice", "crm")
update(identity.Same.m1.b, "asmith", "crm")
update(identity.Same.m2.a, "asmith", "billing")
update(identity.Same.m2.b, "ann", "billing")

The CRM says alice and asmith are one person, and billing says asmith and ann are. Neither source said anything about alice and ann, and the module concludes it from the two statements together:

import identity

def who_is_alice():
    for r in identity.same_as("alice"):
        print(r)

That prints ann and asmith. Nothing was merged: the three records are as you wrote them, and the conclusion stands only while both statements do. Forget either one and ann drops out of the answer by itself.

Disagreement is reported, not settled. Suppose support says otherwise:

import identity

update(identity.Different.d1.a, "ann", "support")
update(identity.Different.d1.b, "alice", "support")

Now identity.contested() answers alice and ann, and identity.contradictions() answers d1. Every statement still stands. Which source to believe is your decision, and the module makes none.

A possibility is kept apart from the facts. Marketing has a second A. Smith and cannot tell whether it is the same person. A Possibly statement says exactly that, with a and b as on Same:

import identity

update(identity.Record.asmith2.name, "A. Smith", "marketing")
update(identity.Possibly.p1.a, "asmith", "marketing")
update(identity.Possibly.p1.b, "asmith2", "marketing")

identity.possibly_same_as("asmith") answers asmith2, and same_as("asmith") still answers alice and ann and nothing more: a possibility never adds a record to an entity. Nor does it travel along a chain of Same statements: possibly_same_as("alice") answers nothing, because no statement said that alice might be asmith2. The module concludes a possibility into a relation of its own, PossiblySame, which your rules can read by that bare name exactly as they read SameEntity below.

Carrying a fact across the link is yours to write. The module does not know what your records hold, but SameEntity, the relation it concludes from Same statements, can be read by any rule of yours by that bare name. Name your subjects after the records they describe:

class Person:
    email: string

@rule
def email_seen():
    for p in Person, q in Person:
        if SameEntity.p.q == True:
            p.email_seen = q.email

With an email recorded for alice and another for ann, Person.alice.email_seen holds both, and the difference is an ordinary disagreement that conflicts() reports. The Library Reference has the rest: how to include a record’s own value, and what one wrong statement costs.

One habit worth having: a statement matches names exactly, and a misspelt name is not refused — it becomes a record of its own. identity.unrecorded() lists every name your statements use that no record carries, so run it after loading.


Chapter 10 — A third module: argument

A post-mortem, a design review and a diagnosis all end in the same place: a set of claims, some of which cut against others, and the question of which ones are left standing. argument answers that from what your sources said, and decides nothing on its own. Version 1.0.2, at dke.langsyn.com/source/argument/latest/argument.dpy.

You record the claims, and which claim attacks which. An Attack statement’s by names the attacking claim and its target the claim attacked:

import argument

update(argument.Claim.leak.text, "The outage was caused by a memory leak", "ops")
update(argument.Claim.flat.text, "Memory stayed flat for the whole outage", "metrics")

update(argument.Attack.k1.by, "flat", "metrics")
update(argument.Attack.k1.target, "leak", "metrics")

Nothing attacks flat, so it stands, and the claim it attacks falls: argument.undefended() answers leak. Then ops points out that the memory graph has a gap exactly where the outage was:

import argument

update(argument.Claim.gap.text, "The memory graph is missing the outage window", "ops")
update(argument.Attack.k2.by, "gap", "ops")
update(argument.Attack.k2.target, "flat", "ops")

Now gap stands, flat falls to it, and leak — whose only attacker has fallen — stands again. Ask for all three answers at once:

import argument

def where_we_stand():
    for c in argument.unattacked():
        print("stands: " + c)
    for c in argument.reinstated():
        print("stands again: " + c)
    for c in argument.undefended():
        print("falls: " + c)

That prints stands: gap, stands again: leak and falls: flat. Every step followed from a statement, and forgetting k2 puts the answer back where it was.

Some questions it leaves open. Suppose metrics replies that the gap is a display fault, and records that flat attacks gap. Now flat and gap attack each other, and neither one’s fall can be established without first knowing the other’s. argument does not guess: all three claims leave every query, and attackers_of("leak") still shows you where the argument stands. The Library Reference says exactly which claims the module settles.

Supports and weakenings go on the record and decide nothing. A Support statement says one claim speaks for another, and a Weakening that one counts against another without attacking it. Both take by and target, as an attack does, and like an attack a statement counts once both of its fields are written:

import argument

update(argument.Claim.heap.text, "A heap dump shows one cache growing without bound", "ops")
update(argument.Support.s1.by, "heap", "ops")
update(argument.Support.s1.target, "leak", "ops")

update(argument.Claim.deploy.text, "A deploy went out ten minutes before the outage", "release")
update(argument.Weakening.w1.by, "deploy", "release")
update(argument.Weakening.w1.target, "leak", "release")

argument.supporters_of("leak") answers heap, argument.weakeners_of("leak") answers deploy, and argument.weakened() answers leak — and whether leak stands has not moved. A support is not a defence, and a weakening is not an attack. What a support does carry is doubt. Suppose a developer points out that the cache has a size limit:

import argument

update(argument.Claim.capped.text, "That cache has a size limit", "dev")
update(argument.Attack.k3.by, "capped", "dev")
update(argument.Attack.k3.target, "heap", "dev")

Now argument.contested_premises() answers heap: part of the case for leak rests on a claim that is itself attacked. heap falls to capped, and leak is still exactly where it was.

As with identity, a statement names claims exactly and a misspelt name is not refused, so run argument.unrecorded() after loading.


Chapter 11 — A fourth module: agenda

An agent that works on someone’s behalf takes on things to do, owes things to other people, and works under rules about what it may not do. agenda keeps that straight from what you recorded, and never acts on it: the store has no clock, so it does not remind anyone of anything. You ask it what is due at a moment you choose. Version 1.0.1, at dke.langsyn.com/source/agenda/latest/agenda.dpy.

Record a commitment you made and an invoice you owe:

import agenda

update(agenda.Commitment.report.action, "send the quarterly report", "me")
update(agenda.Commitment.report.due, datetime("2026-10-02T17:00:00Z"), "me")
update(agenda.Obligation.invoice.action, "pay the hosting invoice", "hosting")
update(agenda.Obligation.invoice.due, datetime("2026-09-30T00:00:00Z"), "hosting")
update(agenda.Obligation.invoice.owed_to, "hosting", "hosting")

Then ask, on the morning of the first of October:

import agenda

def morning():
    now = datetime("2026-10-01T09:00:00Z")
    for item in agenda.overdue(now):
        print("overdue: " + item)
    for item in agenda.due_within(now, duration("P2D")):
        print("coming up: " + item)

That prints overdue: Obligation.invoice and coming up: Commitment.report. Asking about the same morning next week gives the same answer, because the moment is an argument and not the time you happen to ask.

Moving a date keeps the old one. The report will be late, so move it:

import agenda

update(agenda.Commitment.report.due, datetime("2026-10-05T17:00:00Z"), "me")

It leaves the next two days, and agenda.previous_dues("Commitment.report") answers ['2026-10-02T17:00:00Z']. The date you first promised stays on record. agenda.late() compares a completion with the date now in force, so once you record done_at, finishing on the fourth is not late.

A prohibition with a window. Ops announces a change freeze for the week of the twelfth, and you have a release planned inside it:

import agenda

update(agenda.Commitment.release.action, "deploy", "me")
update(agenda.Commitment.release.due, datetime("2026-10-14T12:00:00Z"), "me")
update(agenda.Prohibition.freeze.action, "deploy", "ops",
       datetime("2026-10-12T00:00:00Z"), datetime("2026-10-19T00:00:00Z"))

agenda.blocked() answers Commitment.release: it is due while deploying is forbidden. Move its due date to the twentieth and blocked() no longer names it. agenda.forbidden(at) asks the other question, what you may not do at a moment, so asked about the fourteenth it still names the release, and asked about the twentieth it names nothing. While the freeze is in force, checkup names every open commitment to deploy under agenda.nothing_forbidden_committed, and every open obligation to deploy under agenda.nothing_forbidden_owed — the sharper case, since that is something you owe and may not do. Nothing is removed or refused: moving the release, or getting the freeze lifted, is your decision.

Goals. A commitment can say what it is for. Record the goal, and name it in the commitment’s serves:

import agenda

update(agenda.Goal.close.text, "Close the quarter cleanly", "me")
update(agenda.Commitment.report.serves, "close", "me")

agenda.serving("close") answers Commitment.report. A goal is met once every commitment serving it is done, so agenda.met() answers nothing yet. Record the report as done:

import agenda

update(agenda.Commitment.report.done_at, datetime("2026-10-04T15:00:00Z"), "me")

and agenda.met() answers close. A commitment names its goal exactly, and a misspelt name in serves is not refused — it becomes a goal of its own, as a misspelt record does in identity — so run agenda.unrecorded() after loading.

Two sources can also disagree about a date. The item then has no one place in time, and agenda.unsettled(now) names it instead of the other queries picking a date for you.


Chapter 12 — A fifth module: quantum

quantum does two things that share nothing but the subject. It simulates a quantum circuit, as values, and it reasons about one recorded as facts: which gates a device cannot run as written, which ran on a stale calibration, and whether a result computed outside the store still holds. Version 1.3.1, at dke.langsyn.com/source/quantum/latest/quantum.dpy.

A state of n qubits is a list of 2ⁿ complex amplitudes, and every gate takes a state and returns the next one, as a Python function would. Nothing is written to the store:

import quantum

psi = quantum.zero(2)
psi = quantum.h(psi, 0)
psi = quantum.cnot(psi, 0, 1)
print(quantum.probabilities(psi))

prints [0.5000000000000001, 0.0, 0.0, 0.5000000000000001], a Bell pair. A run has no randomness of its own, so a measurement takes its uniform value as an argument, and a seeded generator supplies one. Measuring the first qubit of the same pair:

import quantum

psi = quantum.zero(2)
psi = quantum.h(psi, 0)
psi = quantum.cnot(psi, 0, 1)
seed = 20261016
u = quantum.lcg_uniform(seed)
bit, after = quantum.measure(psi, 0, u)
print(bit)
print(quantum.probabilities(after))

prints 0 and [1.0, 0.0, 0.0, 0.0]. The same seed gives the same bit every time; draw the seed outside the run and record it.

That generator has fewer than 2³¹ seeds, so a bit it gave can stand for no secret. When a measured bit is to become part of one, take its value from chacha_uniform, ChaCha20 as RFC 8439 defines it, from a key of eight 32-bit words, at a position you count from 0:

import quantum

psi = quantum.zero(2)
psi = quantum.h(psi, 0)
psi = quantum.cnot(psi, 0, 1)
key = [0x03020100, 0x07060504, 0x0B0A0908, 0x0F0E0D0C,
       0x13121110, 0x17161514, 0x1B1A1918, 0x1F1E1D1C]
bit, after = quantum.measure(psi, 0, quantum.chacha_uniform(key, 0))
print(bit)

prints 0. That key is the one RFC 8439’s examples use, printed in the RFC, so it is no secret. Draw yours in Python with list(struct.unpack("<8I", secrets.token_bytes(32))), on the machine where the secret is to be kept: a key you pass travels to the service in the program, as every argument does. advance_chacha measures a kept circuit from a key the same way advance does from a seed, and answers the position to start from next.

A device is facts: its physical qubits, and the operations it runs natively on each qubit and each ordered pair. dkepy’s dke-quantum-device writes them from a calibration snapshot, and here they are by hand for a two-qubit device that runs sx on qubit 0 and cnot from 0 to 1, and nothing else:

import quantum

update(quantum.Device.toy.qubits, 2, "me")
update(quantum.PhysicalQubit.toy_q0.device, "toy", "me")
update(quantum.PhysicalQubit.toy_q0.index, "0", "me")
update(quantum.PhysicalQubit.toy_q1.device, "toy", "me")
update(quantum.PhysicalQubit.toy_q1.index, "1", "me")
update(quantum.Native.toy_sx0.device, "toy", "me")
update(quantum.Native.toy_sx0.op, "sx", "me")
update(quantum.Native.toy_sx0.a, "0", "me")
update(quantum.Native.toy_sx0.measured_at, datetime("2026-10-01T09:00:00Z"), "me")
update(quantum.Native.toy_cx01.device, "toy", "me")
update(quantum.Native.toy_cx01.op, "cnot", "me")
update(quantum.Native.toy_cx01.a, "0", "me")
update(quantum.Native.toy_cx01.b, "1", "me")
update(quantum.Native.toy_cx01.measured_at, datetime("2026-09-01T09:00:00Z"), "me")

Then a circuit compiled for it, whose gates name the circuit:

import quantum

update(quantum.Circuit.bell.device, "toy", "me")
update(quantum.Gate.bell_1.circuit, "bell", "me")
update(quantum.Gate.bell_1.op, "h", "me")
update(quantum.Gate.bell_1.a, "0", "me")
update(quantum.Gate.bell_1.step, 1, "me")
update(quantum.Gate.bell_2.circuit, "bell", "me")
update(quantum.Gate.bell_2.op, "cnot", "me")
update(quantum.Gate.bell_2.a, "1", "me")
update(quantum.Gate.bell_2.b, "0", "me")
update(quantum.Gate.bell_2.step, 2, "me")
update(quantum.Gate.bell_3.circuit, "bell", "me")
update(quantum.Gate.bell_3.op, "sx", "me")
update(quantum.Gate.bell_3.a, "0", "me")
update(quantum.Gate.bell_3.step, 3, "me")

print(quantum.illegal_gates("bell"))
print(quantum.after("bell_1"))

prints ['bell_1 not_native', 'bell_2 reversed'] and ['bell_2', 'bell_3']. The device runs no h anywhere, and runs cnot on that pair the other way round only; each reason is its own conclusion, so why on it names the facts it stands on. And bell_1 must run before both later gates, since it is not diagonal and shares a qubit with each.

The store keeps no clock, so a calibration is judged at a moment you name, against the oldest you accept:

import quantum

print(quantum.stale_gates("bell", datetime("2026-10-16T00:00:00Z"), duration("P7D")))

prints ['bell_3']: its sx was measured fifteen days before.

A result names what it read. Record a result computed outside the store with the digest of each circuit and device it read, and it holds only while those are what the store holds:

import quantum

update(quantum.Device.toy.digest, "sha256:aaaa", "me")
update(quantum.Circuit.bell.digest, "sha256:bbbb", "me")
update(quantum.Result.bell_check.circuit, "bell", "me")
update(quantum.Result.bell_check.digest, "sha256:bbbb", "me")
update(quantum.Result.bell_check.device, "toy", "me")
update(quantum.Result.bell_check.device_digest, "sha256:aaaa", "me")
update(quantum.Result.bell_check.outcome, "legal", "me")

held = current(quantum.Result.bell_check.holds)
print(held.value)

prints legal. Load a recalibrated device, and the result stops holding:

import quantum

update(quantum.Device.toy.digest, "sha256:cccc", "me")
print(quantum.results_not_holding())

prints ['bell_check recalibrated']. dkepy’s dke.quantum.provenance computes real digests, and writes a result’s lines for you.


Chapter 13 — A sixth module: pqc

A quantum computer able to break RSA and elliptic-curve cryptography would also decrypt what was recorded before it existed. pqc reasons about which uses of that cryptography to move first, from facts you record: where your systems use it, how long what each use protects must stay protected, how long each system takes to migrate, and the year you plan against. Version 1.0.2, at dke.langsyn.com/source/pqc/latest/pqc.dpy.

Record a horizon, two algorithms, what two systems protect and how long they take to move:

import pqc

update(pqc.Horizon.plan.year, 2035, "me")
update(pqc.Algorithm.rsa_2048.quantum, "vulnerable", "me")
update(pqc.Algorithm.rsa_2048.strength, 112, "me")
update(pqc.Algorithm.ml_kem_768.quantum, "resistant", "me")
update(pqc.Data.customer_records.shelf_life, 10, "me")
update(pqc.Data.sessions.shelf_life, 0, "me")
update(pqc.System.billing.migration, 3, "me")
update(pqc.System.portal.migration, 1, "me")
update(pqc.Use.billing_rsa.system, "billing", "me")
update(pqc.Use.billing_rsa.algorithm, "rsa_2048", "me")
update(pqc.Use.billing_rsa.protects, "customer_records", "me")
update(pqc.Use.portal_rsa.system, "portal", "me")
update(pqc.Use.portal_rsa.algorithm, "rsa_2048", "me")
update(pqc.Use.portal_rsa.protects, "sessions", "me")
update(pqc.Use.portal_kem.system, "portal", "me")
update(pqc.Use.portal_kem.algorithm, "ml_kem_768", "me")
update(pqc.Use.portal_kem.protects, "sessions", "me")

print(pqc.priority())
start = current(pqc.Use.billing_rsa.start_by)
print(start.value)

prints ['billing_rsa', 'portal_rsa'] and 2022. Customer records must stay protected for ten years, so billing must be off RSA by 2025, and moving it takes three years. The resistant algorithm is in no answer. dkepy’s dke-pqc-cbom writes the algorithms and uses from a cryptographic bill of materials; the shelf lives, migration times and horizon are yours.

Add a plan for billing, and a deadline you are held to:

import pqc

update(pqc.Use.billing_rsa.migrated, 2028, "me")
update(pqc.Deadline.policy.year, 2030, "me")
update(pqc.Deadline.policy.strength, 112, "me")

print(pqc.exposed())
print(pqc.noncompliant())
print(pqc.remaining(2029))

prints ['billing_rsa', 'portal_rsa'], ['portal_rsa'] and ['portal_rsa']. Billing’s plan beats the deadline and still finishes after 2025, so what it protects in those years can be recorded now and read at the horizon: it is exposed. The portal has no plan at all, so a deadline covers it and it is still on RSA in 2029.

The store keeps no clock: remaining takes the year you ask about. Two sources that disagree about an algorithm or a shelf life are both reasoned about, and pqc.unsettled() names each use where that happened.


Chapter 14 — A seventh module: harmony

harmony marks four-part writing as a harmony teacher marks it: consecutive fifths and octaves, crossed voices, spacing, notes outside a voice’s range or outside the key, and the reading of every chord. It checks a melody’s rhythm against its bars, and it calculates what music theory calculates: frequencies, cents, set classes, twelve-tone matrices and even rhythms. Version 1.0.1, at dke.langsyn.com/source/harmony/latest/harmony.dpy.

A pitch is a MIDI note number, 60 for middle C. Write the four voices of a chorale, a key, and three chords from the bass up:

import harmony

harmony.satb()
harmony.key("C", 0, "major")
harmony.chord(1, "C", 48, 55, 64, 72)
harmony.chord(2, "C", 50, 57, 66, 74)
harmony.chord(3, "C", 43, 55, 62, 71)

for f in harmony.faults():
    print(f)
print(harmony.readings("bass2"))

prints three marks, then ['major']: consecutive fifths: bass and tenor, beats 1 to 2, consecutive octaves: bass and soprano, beats 1 to 2 and outside the key: alto, beat 2. From the first chord to the second every voice rises a step, so the bass and tenor move from a fifth to a fifth, and the bass and soprano from an octave to an octave. The second chord’s F sharp is outside C major, and it is marked although a composer writes it on purpose: D major leads to G here as the dominant of the dominant. A mark is the rule as a teacher states it, not a verdict on the music.

Correct the second chord by writing its beat again:

import harmony

harmony.chord(2, "C", 50, 53, 62, 69)

print(harmony.faults())
print(harmony.readings("bass2"))

prints [] and ['minor']. The tenor and soprano now move against the bass, and D minor uses only notes of the key. Writing a beat again replaces its notes, so every mark they supported is withdrawn. A note written under another source is held beside the first instead, and harmony.unsettled() names it.

Rhythm is counted in a unit you choose. In sixteenths, a bar of four beats is 16 long, with a beat of 4:

import harmony

harmony.bars(2, 16, 4)
harmony.melody([2, 4, 2, 8, 6, 6, 4])

print(harmony.rhythm_faults())
print(harmony.euclid(3, 8))

prints ['syncopated: t2', 'syncopated: t6'] and x..x..x.. Each bar is full, and both marked tones start off a beat and last past the next one. The second bar is three, three and two eighths, and euclid(3, 8), which spreads three onsets as evenly as they go over eight steps, gives the same pattern.

The calculations answer values and write nothing:

import harmony

print(harmony.frequency(60))
print(harmony.cents(1.5))
print(harmony.prime_form([0, 1, 5, 6, 8]))
print(harmony.prime_form([0, 1, 5, 6, 8], False))
print(harmony.interval_vector([0, 4, 7]))

prints 261.6255653005986, 701.9550008653874, [0, 1, 5, 6, 8], [0, 1, 3, 7, 8] and [0, 0, 1, 1, 1, 0]. Middle C is 261.6 hertz with A at 440, and a fifth tuned pure is almost two cents wider than the tempered fifth’s 700. The two prime forms are one set class: published lists pack a set from opposite ends, and prime_form gives either. The last line is a major triad’s interval vector, one minor third, one major third and one fifth.

What harmony does not check is listed in the Library Reference: hidden fifths and octaves, doubling, resolution, melodic intervals and more than four voices.


Chapter 15 — An eighth module: physics

physics checks quantities, units and dimensions, and calculates classical motion. You record each quantity as a value in a unit, and it concludes the value in SI, names a quantity handed over in a unit other than the one its reader expects, judges whether an equation is dimensionally consistent, and adds the resistance of resistors joined in series or in parallel. Its value scripts calculate a pendulum, an orbit under three integrators, the heat equation, a linear system, a bridge circuit and the Ising model of a magnet. Version 1.0.3, at dke.langsyn.com/source/physics/latest/physics.dpy.

A unit is a name and a dimension, its powers of mass, length, time, electric current and temperature, and what one of it is in SI. physics.si_units() writes a list of common ones. A spacecraft’s thrust is reported in pound-force seconds to a program that reads newton seconds:

import physics

physics.si_units()
physics.quantity("impulse", 100.0, "lbf_s")
physics.expect("impulse", "N_s")

print(physics.checks())
print(physics.si("impulse"))
print(physics.converted("impulse"))

physics.quantity("impulse", 444.822, "N_s")
print(physics.checks())

prints ['handed over: impulse in lbf_s where N_s is expected, convert'], 444.82216152604997, 444.82216152604997 and []. The two units have the same dimension, so the value converts, and si and converted agree because the unit expected is the SI one. The mark is the hand-over, not an error in the number. Writing the quantity again in newton seconds replaces it, and the mark is withdrawn. Had the reader expected a unit of another dimension, the mark would read incompatible, and no factor makes it right.

An equation is checked as a product of powers. Write every power on the left side positive and every power on the right side negative, so F = m a is F to the 1, m to the -1 and a to the -1:

import physics

physics.si_units()
physics.quantity("F", 12.0, "N")
physics.quantity("m", 3.0, "kg")
physics.quantity("a", 4.0, "m_per_s2")
physics.quantity("E", 6.0, "J")
physics.quantity("v", 2.0, "m_per_s")

physics.factor("newton", "F", 1)
physics.factor("newton", "m", -1)
physics.factor("newton", "a", -1)

physics.factor("kinetic", "E", 1)
physics.factor("kinetic", "m", -1)
physics.factor("kinetic", "v", -2)

physics.factor("wrong", "E", 1)
physics.factor("wrong", "m", -1)
physics.factor("wrong", "v", -1)

for e in ["newton", "kinetic", "wrong"]:
    print(e, physics.dimension(e))
print(physics.checks())

prints newton consistent, kinetic consistent, wrong L1 T-1 and ['unbalanced: wrong, L1 T-1 left over']. Force is mass times acceleration, and the energy of a body is its mass times its speed squared, with the 1/2 left out because a pure number has no dimension. E = m v is neither: it leaves a length per time over, which dimension writes as L1 T-1. Consistent means the dimensions agree, not that the equation is true.

Resistors in series add their resistances, and in parallel their conductances:

import physics

physics.group("pair", "parallel")
physics.resistor("r1", 100.0, "pair")
physics.resistor("r2", 100.0, "pair")
physics.group("chain", "series")
physics.resistor("r3", 100.0, "chain")
physics.resistor("r4", 220.0, "chain")

print(physics.resistance("pair"))
print(physics.resistance("chain"))

physics.resistor("r2", 300.0, "pair")
print(physics.resistance("pair"))

prints 50.0, 320.0 and 75.0. Two 100 ohm resistors in parallel are 50 ohms, and 100 and 220 ohms in series are 320. The last three lines write the second resistor again at 300 ohms, which replaces its first value, and the parallel pair is now 75 ohms.

Where two sources disagree, both values are reasoned about and nothing refuses:

import physics

physics.si_units()
physics.quantity("span", 10.0, "ft")
physics.expect("span", "m")
update(physics.Quantity("span").value, 12.0, "surveyor")

print(physics.unsettled())
print(physics.si("span"))
print(physics.checks())

physics.group("loop", "series")
physics.resistor("ra", 100.0, "loop")
physics.resistor("rb", 50.0, "loop")
update(physics.Resistor("ra").ohms, 120.0, "meter")
print(physics.unsettled())
print(physics.disputed_groups())
forget(physics.Resistor("ra").ohms, 120.0, "meter")
print(physics.resistance("loop"))

prints ['quantity span'], 3.048, ['handed over: span in ft where m is expected, convert'], ['quantity span', 'resistor ra'], ['loop'] and 150.0. A survey gave the span as 10 feet and another source as 12. unsettled() names the quantity, and si answers the least of the values, 3.048 metres. The resistor is the case to watch: a resistance is a sum, and a sum adds every value it reads, so the series group would read 100 plus 120 plus 50, 270 ohms, which is no circuit anyone built. While a resistor is bound to its group twice, the group is disputed and has no resistance: disputed_groups() names it, resistance refuses it, and forgetting the claim that is not to stand settles it. A second source giving the same 100 ohms disputes a series group too, since the sum would add it twice.

The calculations answer values and write nothing:

import physics

print(physics.small_period(1.0, 9.80665))
print(physics.period(1.0, 1.0, 9.80665))
print(physics.pendulum(1.0, 1.0, 9.80665, 0.001))
print(physics.orbit(0.5, "euler", 10, 400))
print(physics.orbit(0.5, "rk4", 10, 400))
print(physics.heat(20, 0.25, 40))
print(physics.heat_exact(0.025))
print(physics.bridge(10.0, 100.0, 200.0, 100.0, 200.0, 50.0))
print(physics.bridge(10.0, 100.0, 200.0, 100.0, 210.0, 50.0))
print(physics.solve([[2.0, 1.0], [1.0, 3.0]], [3.0, 5.0]))
print(physics.solve([[1.0, 2.0], [2.0, 4.0]], [3.0, 6.0]))

prints, one to a line, 2.0064092925890407, 2.139502939337562, 2.1395029388040956, [0.7650100270596601, 0.37507303664083647, 5.398487191688995], [-2.898865276712569e-07, -4.485077864423118e-08, 3.506398058980124e-05], 0.644289254804568, 0.6431765995475461, 0.0, -0.0005830903790087305, [0.8, 1.4] and []. A one metre pendulum released at one radian swings with a period of 2.1395 seconds, where the small-angle formula gives 2.0064, and integrating its equation of motion agrees with the exact answer to nine places. The Euler method ends ten periods with its orbit’s energy off by three quarters, and the fourth-order Runge–Kutta method by three parts in ten million. The finite rod’s middle is within 0.0012 of Fourier’s series, a balanced bridge passes no current through its meter and an unbalanced one a little, and a singular linear system has no solution and answers [].

What physics does not check is listed in the Library Reference: whether an equation is true, a unit absent from the list, and a circuit that is not built of series and parallel groups.


Chapter 16 — A ninth module: chemistry

chemistry reads molecular structure the way a chemist reads a skeletal drawing. You write a molecule’s atoms and the bonds between them. It works out the hydrogens each atom carries, the molecule’s formula and weight, and the functional groups on its atoms. It adds up a reaction’s enthalpy from the enthalpies of formation of its species, and it calculates what a chemistry course calculates: molar masses, balanced equations, the pH of an acid, a titration curve and the kinetics of consecutive and of reversible reactions. Version 1.0.3, at dke.langsyn.com/source/chemistry/latest/chemistry.dpy.

Hydrogens are not written. Write acetic acid by its four heavy atoms and three bonds; chemistry.elements() first writes the table of elements the rules read:

import chemistry

chemistry.elements()
chemistry.atom("c1", "C", "acid")
chemistry.atom("c2", "C", "acid")
chemistry.atom("o1", "O", "acid")
chemistry.atom("o2", "O", "acid")
chemistry.bond("c1", "c2", 1)
chemistry.bond("c2", "o1", 2)
chemistry.bond("c2", "o2", 1)

w = chemistry.weight("acid")
print(chemistry.formula("acid"), round(w, 3))
print(chemistry.groups("acid"))
for n in ["c1", "c2", "o1", "o2"]:
    h = current(chemistry.Atom(n).hydrogens)
    print(n, h.value)

prints C2H4O2 60.052, ['carboxylic acid'], then the hydrogens of the four atoms: c1 3, c2 0, o1 0 and o2 1. Each atom carries what its valence leaves over. The methyl carbon has one bond and a valence of 4, so it carries three hydrogens; the carbonyl carbon has bonds of order 1, 2 and 1, four in all, and carries none; the oxygen with one bond carries one. The carbon double-bonded to an oxygen is a carbonyl carbon, and with a single bond to an oxygen that carries a hydrogen it is an acid’s. The formula is in the Hill order, carbon first and hydrogen next, and the weight is the sum of the atoms’ weights with their hydrogens.

Correct a structure by writing a bond again. Here a chain of two carbons and an oxygen is first an alcohol, then, with the oxygen double-bonded, an aldehyde. A carbon bonded past its valence is marked, not refused:

import chemistry

chemistry.elements()
chemistry.atom("a1", "C", "chain")
chemistry.atom("a2", "C", "chain")
chemistry.atom("a3", "O", "chain")
chemistry.bond("a1", "a2", 1)
chemistry.bond("a2", "a3", 1)
print(chemistry.formula("chain"), chemistry.groups("chain"))

chemistry.bond("a2", "a3", 2)
print(chemistry.formula("chain"), chemistry.groups("chain"))

w = why(chemistry.Atom.a2.group)
for c in w:
    p = str(c.path)
    if "Bond" in p and ".order" in p:
        print(p + " = " + str(c.value))

chemistry.atom("x1", "C", "over")
chemistry.atom("x2", "O", "over")
chemistry.atom("x3", "O", "over")
chemistry.atom("x4", "C", "over")
chemistry.bond("x1", "x2", 2)
chemistry.bond("x1", "x3", 2)
chemistry.bond("x1", "x4", 1)
print(chemistry.faults())

prints C2H6O ['alcohol'], then C2H4O ['aldehyde'] after the correction. Writing the bond again replaced it, so the alcohol and its hydrogens were withdrawn and the aldehyde concluded. why names the bonds the group rests on: chemistry.Bond.a2-a3.order = 2 and chemistry.Bond.a2-a1.order = 1, which with the double bond leave the carbon its one hydrogen. The last line is ['overbonded: x1']: the first carbon has two double bonds and a single bond, five in all against a valence of 4.

A reaction is written as species with coefficients, negative for a reactant and positive for a product. The reaction’s enthalpy is the sum of each coefficient times its species’ enthalpy of formation:

import chemistry

chemistry.species("methane_g", -74.8)
chemistry.species("oxygen_g", 0.0)
chemistry.species("carbon_dioxide_g", -393.5)
chemistry.species("water_l", -285.8)
chemistry.term("burn", "methane_g", -1)
chemistry.term("burn", "oxygen_g", -2)
chemistry.term("burn", "carbon_dioxide_g", 1)
chemistry.term("burn", "water_l", 2)
h = chemistry.enthalpy("burn")
print(round(h, 1))

chemistry.term("burn_gas", "methane_g", -1)
chemistry.term("burn_gas", "oxygen_g", -2)
chemistry.term("burn_gas", "carbon_dioxide_g", 1)
chemistry.term("burn_gas", "water_g", 2)
h = chemistry.enthalpy("burn_gas")
print(round(h, 1), chemistry.faults())

chemistry.species("water_g", -241.8)
h = chemistry.enthalpy("burn_gas")
print(round(h, 1), chemistry.faults())

prints -890.3, then -318.7 ['incomplete: burn_gas'], then -802.3 []. Burning methane to liquid water gives off 890.3 kJ. The second reaction names water_g, which has no enthalpy of formation yet, so its enthalpy is the sum of the three terms that have one and faults() says the reaction is incomplete. Writing the species completes it, and the enthalpy is concluded again.

Where two sources disagree about a fact, both values are reasoned about, and unsettled() names the fact. A sum is the exception:

import chemistry

chemistry.elements()
chemistry.atom("s1", "S", "sulfane")
w = chemistry.weight("sulfane")
print(chemistry.formula("sulfane"), round(w, 3))

update(chemistry.Element("S").weight, 32.065, "older table")
print(chemistry.unsettled(), chemistry.disputed_molecules())
print(chemistry.formula("sulfane"))
forget(chemistry.Element("S").weight, 32.065, "older table")
w = chemistry.weight("sulfane")
print(round(w, 3))

chemistry.species("methane_g", -74.8)
chemistry.species("oxygen_g", 0.0)
chemistry.species("carbon_dioxide_g", -393.5)
chemistry.species("water_l", -285.8)
chemistry.term("burn", "methane_g", -1)
chemistry.term("burn", "oxygen_g", -2)
chemistry.term("burn", "carbon_dioxide_g", 1)
chemistry.term("burn", "water_l", 2)
h = chemistry.enthalpy("burn")
print(round(h, 1))

update(chemistry.Species("methane_g").formation, -74.6, "other table")
print(chemistry.unsettled(), chemistry.disputed_reactions())
print(chemistry.faults())

prints H2S 34.076, ['element S'] ['sulfane'], H2S and 34.076, then -890.3, ['species methane_g'] ['burn'] and ['disputed: reaction burn']. A weight and an enthalpy are sums, and a sum adds every value it reads: the sulfane would be weighed once at each weight, 68.157, which is no weight of the molecule, and the reaction would count methane at both enthalpies. So while a member is bound to a sum twice, the sum is disputed and has no value: disputed_molecules() and disputed_reactions() name it, weight() and enthalpy() refuse it, and faults() lists it. What the dispute does not reach stands, so the formula, whose hydrogens both weights agree on, still reads H2S. Forgetting the claim that is not to stand settles it, and the weight is 34.076 again. A bond whose order a second source writes, even the same order, disputes its atom and so its molecule, which is not weighed without it.

The calculations answer values and write nothing. They need no molecule, only a formula, an equation or a rate:

import chemistry

chemistry.elements()
w = chemistry.molar_mass("Ca3(PO4)2")
print(round(w, 3))
print(chemistry.balance(["C3H8", "O2"], ["CO2", "H2O"]))
print(chemistry.balance(["Cr2O7^2-", "Fe^2+", "H^+"], ["Cr^3+", "Fe^3+", "H2O"]))
print(chemistry.balance(["H2", "O2"], ["H2O", "H2O2"]))

p = chemistry.ph(0.1, [4.756])
print(round(p, 3))
p = chemistry.titration(0.1, 25.0, 0.1, 12.5, [4.756])
print(round(p, 3))
p = chemistry.titration(0.1, 25.0, 0.1, 25.0, [4.756])
print(round(p, 3))

t = chemistry.peak_time(1.0, 0.5)
print(round(t, 4))
f = chemistry.consecutive(1.0, 0.5, 1.0, 0.01)
print([round(x, 4) for x in f])
g = chemistry.consecutive_exact(1.0, 0.5, 1.0)
print([round(x, 4) for x in g])
c = chemistry.equilibrium_exact(10.0, 1.0, 1.0)
print(round(c, 4))

prints 310.174, then [1, 5, 3, 4], [1, 6, 14, 2, 6, 7] and [], then 2.881, 4.756 and 8.728, then 1.3863, [0.3679, 0.4773, 0.1548], [0.3679, 0.4773, 0.1548] and 0.7298. Propane burns as C3H8 + 5 O2 to 3 CO2 + 4 H2O, and the dichromate and iron equation balances with its charge. The third equation answers []: hydrogen and oxygen make water and hydrogen peroxide in more than one independent proportion, so there is no single balance. The pH of 0.1 mol/L acetic acid is 2.881, and halfway through its titration the pH equals the acid’s pKa, 4.756. At equal volumes of acid and base the solution is the salt alone and the pH is 8.728. For A to B to C with rate constants 1 and 0.5, B is at its most at 1.3863 time units, and the Runge-Kutta fractions at time 1 agree with the closed form to four places. At equilibrium with a constant of 10 and 1 mol/L of each reactant, 0.7298 mol/L of C has formed.

What chemistry reads is the bonds: it does not represent charge, rings, aromaticity or stereochemistry, nor a second valence for an element. The Library Reference lists what the answers can and cannot mean.


Chapter 17 — A tenth module: biology

biology reads two kinds of record. From a metabolic network, a growth medium and the reactions between compounds, it concludes which reactions can run and which compounds the network can make, and it asks which one compound would let it make one it cannot. From a pedigree, it concludes every person’s ancestors, each child whose parents are related by descent, the carriers the mode of inheritance requires, and each person whose record the mode cannot explain. It also calculates: translation, crosses, Hardy–Weinberg proportions, drift and a ring of genes. Version 1.0.2, at dke.langsyn.com/source/biology/latest/biology.dpy.

A network is a medium, which marks the compounds it supplies, and reactions of one or two substrates and one or two products. Write a medium of glucose and ATP, three reactions that follow from it, and one that nothing supplies:

import biology

biology.medium("glucose")
biology.medium("atp")
biology.reaction("hexokinase", "glucose", "atp", "g6p", "adp")
biology.reaction("isomerase", "g6p", "", "f6p", "")
biology.reaction("kinase2", "f6p", "atp", "fbp", "adp")
biology.reaction("epimerase", "x5p", "", "ru5p", "")

print(biology.scope())
print(biology.blocked())

prints ['adp', 'atp', 'f6p', 'fbp', 'g6p', 'glucose'], then ['epimerase']. The scope is every compound the network can make from the medium, the medium’s own included. Each of the first three reactions has every substrate reached, so it runs, and its products are reached in turn. epimerase needs x5p, which nothing makes, so it is blocked and ru5p is outside the scope.

needs() asks which single compound, added to the medium, would let the network make a compound it cannot:

import biology

print(biology.needs("ru5p", ["xylose", "ribose", "x5p"]))
print(biology.scope())

prints ['x5p'] and the same scope as before. Each candidate is a hypothesis: the module supposes it in the medium, asks whether ru5p is then reached, and writes nothing. Of the three, only x5p is the substrate of a reaction that makes ru5p.

A scope is held up from the medium, and a cycle of reactions does not hold itself up. Add a pyruvate that f6p makes and a pair of reactions that turn it into lactate and back, then take glucose out of the medium by writing it False under its own source:

import biology

biology.reaction("ferment", "f6p", "", "pyruvate", "")
biology.reaction("forward", "pyruvate", "", "lactate", "")
biology.reaction("back", "lactate", "", "pyruvate", "")
print(biology.scope())

update(biology.Compound("glucose").medium, False, "medium")
print(biology.scope())
print(biology.blocked())

update(biology.Compound("glucose").medium, True, "medium")
print(biology.scope())

prints four lines. The first scope has lactate and pyruvate in it. With glucose gone, the second is ['atp']: only the medium’s other compound is left, and lactate and pyruvate are not reached although each is the other’s product. blocked() lists seven reactions, the whole chain and the cycle with it, and the fourth line is the first scope again, restored by writing glucose back. A conclusion withdraws when the fact it rests on does.

A pedigree is families and people. A family has a mode of inheritance, and a person a family, a sex, whether they are affected, and a mother and father where known. Record a family with an X-linked recessive trait over three generations, and ask for its carriers:

import biology

biology.family("harrow", "X-linked recessive")
biology.person("edda", "harrow", "F", False, "", "")
biology.person("osk", "harrow", "M", False, "", "")
biology.person("bryn", "harrow", "M", True, "edda", "osk")
biology.person("tove", "harrow", "F", False, "edda", "osk")
biology.person("nell", "harrow", "F", True, "tove", "osk")

print(biology.carriers("harrow"))
print(biology.unexplained("harrow"))
print(biology.ancestors("nell"))
print(biology.inbred())
w = why(biology.Person.edda.carrier)
for c in w:
    print(str(c.path) + " = " + str(c.value))

prints ['edda'], ['nell'], ['edda', 'osk', 'tove'] and ['nell'], then the facts the first conclusion rests on, one to a line, from biology.Person.edda.carrier = True down: the family’s mode, X-linked recessive; Bryn’s family, his sex, M, his being affected and his mother, Edda; and Edda’s being unaffected.

A son’s one X is his mother’s, so Edda, unaffected, carries the allele because her son Bryn is affected. Nell is an affected daughter, which needs an affected father, and Osk is not: the mode cannot explain her record, so she is unexplained. She is also inbred, because her father Osk is her mother Tove’s father too. These conclusions assume the trait shows in everyone who has its genotype and that no new mutation arose in the family. Nell is where one of those, or the record, does not hold.

A correction is the same source writing again. A disagreement is another source writing. Say the clinic’s record of Nell was wrong, then let a second clinic record her as affected:

import biology

biology.person("nell", "harrow", "F", False, "tove", "osk")
print(biology.unexplained("harrow"))

update(biology.Person("nell").affected, True, "second clinic")
print(biology.unsettled())
print(biology.unexplained("harrow"))

prints [], then ['person nell affected'] and ['nell']. Writing Nell again under pedigree replaced her first record, so no one is unexplained. The second clinic’s claim is held beside it instead of replacing it, and both are reasoned about: Nell is unexplained again because one value on record makes her so, and unsettled() names the person and the field. No query refuses.

The calculations answer values and write nothing:

import biology

rna = biology.transcribe("CCATGGCTTGGTAAGG")
print(rna)
print(biology.translate(rna, 1))
print(biology.translate("AUGAUAUGAGCC", 1))
print(biology.translate("AUGAUAUGAGCC", 2))
print(biology.phenotypes("AaBb", "AaBb"))
print(biology.chi_square([315.0, 108.0, 101.0, 32.0], [9.0, 3.0, 3.0, 1.0]))
print(biology.hardy_weinberg(50, 40, 10))
print(biology.drift(50, 0.5, 100, 7))
print(biology.ring("100", 3))
print(biology.period("100"))

prints CCAUGGCUUGGUAAGG, MAW, MI, MMWA, {'AB': 9, 'Ab': 3, 'aB': 3, 'ab': 1}, 0.4700239808153477, [0.7, 0.226757369614513], [0.0, 58.0], ['100', '101', '001', '011'] and 6. The translation starts at the first AUG and stops at the stop codon UAA. The same message reads MI in the standard code and MMWA in the vertebrate mitochondrial one, which reads AUA as methionine and UGA as tryptophan. A dihybrid cross gives nine, three, three and one; the chi-square of Mendel-like counts against that ratio is 0.47. The allele frequency from the genotype counts is 0.7, and its counts differ little from random mating’s proportions. drift with a seed is the same run every time, and this one lost the allele in generation 58. A ring of three genes that repress each other returns to its first state after six steps.

What biology does not check is listed in the Library Reference: the energy, balance or direction of a reaction, and the penetrance of a trait.

Chapter 18 — An eleventh module: astronomy

astronomy reads two kinds of record. From sites and stars, it concludes for each site the stars that never set, the stars that never rise and the stars it can see. From nights and bookings, it concludes that an instrument can observe a star for exactly the stretch where the booking, the dark and the star’s time above the horizon overlap, and it reports that stretch. It also calculates: Julian dates, sidereal time, rising and setting, the Sun and the length of the day, and orbits. Version 1.0.2, at dke.langsyn.com/source/astronomy/latest/astronomy.dpy.

A site has a latitude, a longitude and the faintest magnitude its instrument reaches. A star has a right ascension, a declination and a magnitude. Angles are in degrees, and a smaller magnitude is a brighter star. Write two sites and five stars, and ask what each site sees:

import astronomy

astronomy.site("oslo", 59.91, 10.75, 6.0)
astronomy.site("hobart", -42.88, 147.33, 6.0)
astronomy.star("polaris", 37.95, 89.26, 1.98)
astronomy.star("vega", 279.23, 38.78, 0.03)
astronomy.star("canopus", 95.99, -52.70, -0.74)
astronomy.star("sirius", 101.29, -16.72, -1.46)
astronomy.star("faint", 10.0, 20.0, 9.0)

print(astronomy.circumpolar("oslo"))
print(astronomy.hidden("oslo"))
print(astronomy.visible("oslo"))
print(astronomy.circumpolar("hobart"))
print(astronomy.hidden("hobart"))
print(astronomy.visible("hobart"))

prints ['polaris', 'vega'], ['canopus'], ['polaris', 'sirius', 'vega'], ['canopus'], ['polaris'] and ['canopus', 'sirius', 'vega']. At Oslo, 59.91 degrees north, a star north of 30.09 degrees never sets, and a star south of -30.09 never rises. Polaris and Vega stay up; Canopus, at -52.70, never clears the horizon. At Hobart, 42.88 degrees south, it is the other way about: Canopus never sets and Polaris never rises. Sirius rises and sets at both. faint is seen at neither, because its magnitude, 9.0, is fainter than the 6.0 each site’s instrument reaches. visible includes a star that never sets, since it is above the horizon all the time.

The limits are computed from the latitude, and a conclusion is withdrawn when the fact it rests on is corrected. Read the limits, ask why Vega is circumpolar at Oslo, then correct Vega’s declination to 28.0 and back:

import astronomy

astronomy.site("oslo", 59.91, 10.75, 6.0)
astronomy.site("hobart", -42.88, 147.33, 6.0)
astronomy.star("polaris", 37.95, 89.26, 1.98)
astronomy.star("vega", 279.23, 38.78, 0.03)
astronomy.star("canopus", 95.99, -52.70, -0.74)
astronomy.star("sirius", 101.29, -16.72, -1.46)

limit = astronomy.Site("oslo").circumpolar_above
print(limit)
low = astronomy.Site("oslo").hidden_below
print(low)
south = astronomy.Site("hobart").circumpolar_below
print(south)

w = why(astronomy.Circumpolar.vega.oslo)
for c in w:
    print(str(c.path) + " = " + str(c.value))

astronomy.star("vega", 279.23, 28.0, 0.03)
print(astronomy.circumpolar("oslo"))
astronomy.star("vega", 279.23, 38.78, 0.03)
print(astronomy.circumpolar("oslo"))

prints 30.090000000000003, -30.090000000000003 and -47.12, then four lines: astronomy.Circumpolar.vega.oslo = True, then the two facts it rests on, Oslo’s latitude and its limit computed from it, astronomy.Site.oslo.lat = 59.91 and astronomy.Site.oslo.circumpolar_above = 30.090000000000003, and Vega’s declination, astronomy.Star.vega.dec = 38.78. With the declination at 28.0, below the limit, only Polaris is left, ['polaris']. Writing the right value back restores ['polaris', 'vega'].

A night is the dark time at a site, and a booking is an instrument booked at a site for a star. Neither says when the star is up, so write that with up(), passing the night’s start. The booking is observable where all three overlap:

import astronomy

astronomy.site("oslo", 59.91, 10.75, 6.0)
astronomy.site("hobart", -42.88, 147.33, 6.0)
astronomy.star("polaris", 37.95, 89.26, 1.98)
astronomy.star("vega", 279.23, 38.78, 0.03)
astronomy.star("canopus", 95.99, -52.70, -0.74)
astronomy.star("sirius", 101.29, -16.72, -1.46)

astronomy.night("n1", "oslo", datetime("2026-11-08T16:00:00Z"), datetime("2026-11-09T06:00:00Z"))
astronomy.up("vega", "oslo", datetime("2026-11-08T16:00:00Z"))
astronomy.book("b1", "oslo", "vega", datetime("2026-11-08T18:00:00Z"), datetime("2026-11-08T22:00:00Z"))
print(astronomy.window("b1"))

astronomy.up("sirius", "oslo", datetime("2026-11-08T16:00:00Z"))
astronomy.book("b2", "oslo", "sirius", datetime("2026-11-08T18:00:00Z"), datetime("2026-11-09T05:00:00Z"))
print(astronomy.window("b2"))

astronomy.book("b3", "oslo", "canopus", datetime("2026-11-08T18:00:00Z"), datetime("2026-11-09T05:00:00Z"))
print(astronomy.window("b3"))

print(astronomy.observable_at(datetime("2026-11-08T20:00:00Z")))
print(astronomy.observable_at(datetime("2026-11-09T01:00:00Z")))
print(astronomy.observable_at(datetime("2026-11-09T05:30:00Z")))

prints ['2026-11-08T18:00:00Z', '2026-11-08T22:00:00Z'], then ['2026-11-08T22:54:30.745764485Z', '2026-11-09T05:00:00Z'], then [], then ['b1'], ['b2'] and []. Vega never sets at Oslo, so its up() is the whole day from the start and the first booking is observable for all of its four hours. Sirius rises at 22:54, inside the second booking, which ends at 05:00, before the night does and before Sirius sets: the booking is observable from the rising to its end. Canopus never rises at Oslo, so up() wrote nothing for it and the third booking is never observable. Reading at an instant answers which bookings are observable then: only the first at 20:00, only the second at 01:00, and neither at 05:30. The conclusion is the overlap of three windows, and window() reports it from the last of them opening to the first closing.

A disagreement is another source writing. A second catalogue puts Canopus at -20.0 degrees:

import astronomy

update(astronomy.Star("canopus").dec, -20.0, "second catalogue")
print(astronomy.unsettled())
print(astronomy.hidden("oslo"))
print(astronomy.visible("oslo"))

prints ['star canopus dec'], ['canopus'] and ['canopus', 'polaris', 'sirius', 'vega']. Both values are reasoned about: Canopus is hidden at Oslo by the first and visible by the second, so it is in both lists, and unsettled() names the star and the field. No query refuses except up() and window(): up() needs one declination for the star, and is refused until the disagreement is settled, and window() needs one start and one end for the booking.

The calculations answer values and write nothing. Times are in UT, and a Julian date counts days:

import astronomy

print(astronomy.jd(2000, 1, 1.5))
j = astronomy.jd(1987, 4, 10.0)
print(j)
g = astronomy.gmst(j)
print(g)
print(astronomy.hms(g))
print(astronomy.altitude(38.78, 59.91, 0.0))
print(astronomy.semi_arc(-16.72, 59.91, 0.0))
print(astronomy.rise_set(101.29, -16.72, 59.91, 10.75, datetime("2026-11-08T00:00:00Z")))
print(astronomy.day_length(59.91, astronomy.jd(2026, 6, 21.0)))
print(astronomy.day_length(59.91, astronomy.jd(2026, 12, 21.0)))
print(astronomy.day_length(80.0, astronomy.jd(2026, 6, 21.0)))
print(astronomy.day_length(80.0, astronomy.jd(2026, 12, 21.0)))

prints 2451545.0, 2446895.5, 197.693195090862, [13.0, 10.0, 46.36682180688041], 68.86999999999999, 58.77387884370289, [2026-11-07T22:58:26.655225834Z, 2026-11-08T02:52:53.871385659Z, 2026-11-08T06:47:21.087545484Z], 18.830588577027644, 5.899388616579758, 24.0 and 0.0. The first is the Julian date of noon on 2000 January 1. The Greenwich sidereal time at 0h on 1987 April 10 is 13 hours 10 minutes 46.37 seconds, the value Meeus works the formula out to. Vega, at declination 38.78, stands 68.87 degrees high where it crosses the meridian at Oslo, 90 minus the 21.13 degrees between the latitude and the declination. The semi-arc of Sirius is 58.77 degrees, nearly four hours each side of its transit, and rise_set() gives its rising, transit and setting around the transit on that UT day, the rising falling the evening before. At Oslo the day is 18.8 hours long at midsummer and 5.9 at midwinter; at 80 degrees north the Sun does not set at midsummer and does not rise at midwinter.

The orbits are solved two ways. Kepler’s equation is solved by Newton’s method, and a body is carried round an orbit by fourth-order Runge-Kutta, which can be checked against it:

import astronomy

k = astronomy.kepler(0.5, 0.1)
print(k)
print(astronomy.true_anomaly(k[0], 0.1))
print(astronomy.two_body(0.5, 1.0, 200))
print(astronomy.two_body(0.5, 1.0, 1000))

prints [0.5524799869065704, 4.0], 0.6074229151773668, [-0.5000000000000002, -0.5000004631075587, 2.5973556467815207e-05] and [-0.5000000000000002, -0.5000000001484903, 3.154061045708898e-08]. Newton’s method took four steps to find the eccentric anomaly for a mean anomaly of 0.5 and an eccentricity of 0.1, all in radians. The last two lines carry a body of eccentricity 0.5 once round, from perihelion, in 200 steps and then in 1000. The energy of such an orbit is -0.5 and should not change; after 200 steps it differs in the seventh decimal place and after 1000 in the tenth, and the body is within 2.6e-5, then 3.2e-8, of where Kepler’s equation puts it.

What astronomy does not check is listed in the Library Reference: the atmosphere, the Moon, the weather, the precession of the coordinates, and whether a star’s magnitude is within a booking’s reach.

Chapter 19 — A twelfth module: geography

geography reads three kinds of record. From regions and the region each lies in, it concludes every region a region lies within and how many regions each one holds. From a map, the borders between regions and the colour each is given, it concludes who neighbours whom and which neighbours share a colour, and it can choose colours so that none do. From places and boxes of latitude and longitude, it concludes which places lie in which boxes. It also calculates distances, bearings, projections and areas on the Earth. Version 1.0.2, at dke.langsyn.com/source/geography/latest/geography.dpy.

A hierarchy is regions that name their parent. Each has a level, any word you use. Record a country, two counties, the municipalities in them and a district in one, outermost first, and ask what lies within what:

import geography

geography.region("norway", "", "country")
geography.region("vestland", "norway", "county")
geography.region("rogaland", "norway", "county")
geography.region("bergen", "vestland", "municipality")
geography.region("askoy", "vestland", "municipality")
geography.region("stavanger", "rogaland", "municipality")
geography.region("nordnes", "bergen", "district")

print(geography.within("nordnes"))
print(geography.regions_in("norway", "municipality"))
print(geography.regions_in("vestland", ""))
h = current(geography.Region.norway.holds)
print(h.value)

prints ['bergen', 'norway', 'vestland'], ['askoy', 'bergen', 'stavanger'], ['askoy', 'bergen', 'nordnes'] and 6. Nordnes lies within Bergen, which lies within Vestland, which lies within Norway: the rules go up the parents to any depth. regions_in goes the other way, down from a region, and takes a level or "" for any. Norway holds six regions, every one recorded below it. The outermost region is written with "" for its parent, and every region has to be written with geography.region for the rules to conclude anything about it.

A conclusion withdraws when the fact it rests on is corrected. Say Askøy belongs to Rogaland, and ask why it lies within Norway:

import geography

geography.region("norway", "", "country")
geography.region("vestland", "norway", "county")
geography.region("rogaland", "norway", "county")
geography.region("bergen", "vestland", "municipality")
geography.region("askoy", "vestland", "municipality")
geography.region("stavanger", "rogaland", "municipality")
geography.region("nordnes", "bergen", "district")

geography.region("askoy", "rogaland", "municipality")
print(geography.within("askoy"))
print(geography.regions_in("vestland", ""))
print(geography.regions_in("rogaland", "municipality"))
w = why(geography.Within.askoy.norway)
for c in w:
    print(str(c.path) + " = " + str(c.value))

prints ['norway', 'rogaland'], ['bergen', 'nordnes'] and ['askoy', 'stavanger'], then the facts the last conclusion rests on, one to a line, from geography.Within.askoy.norway = True down: Askøy within Rogaland, then the two written facts that give it, that Askøy’s parent is Rogaland and that Rogaland’s parent is Norway. Writing Askøy again under the same source replaced its first parent, so it left Vestland and everything that rests on Vestland.

A map is borders and colours. Add two more counties, give each region a border where it touches another, and colour the map by hand:

import geography

geography.region("innlandet", "norway", "county")
geography.region("agder", "norway", "county")
geography.border("vestland", "rogaland")
geography.border("vestland", "innlandet")
geography.border("rogaland", "agder")
geography.border("agder", "vestland")
geography.colour("vestland", "green")
geography.colour("rogaland", "green")
geography.colour("innlandet", "yellow")
geography.colour("agder", "red")

print(geography.neighbours("vestland"))
print(geography.clashes())
print(geography.dsatur())
geography.paint(["green", "yellow", "red", "blue"])
print(geography.clashes())
c = current(geography.Region.vestland.colour)
print(c.value)

prints ['agder', 'innlandet', 'rogaland'], ['rogaland vestland'], {'vestland': 0, 'agder': 1, 'rogaland': 2, 'innlandet': 1}, [] and green. Vestland borders three counties. Rogaland and Vestland touch and are both green, so the map has a clash, named once with the earlier name first. dsatur() chooses a number for every region a border names so that no two neighbours share one: Vestland, Rogaland and Agder all touch each other, so they take three numbers. It writes nothing. paint writes them, number k as the kth colour of the palette, in place of the colours written by hand, and the map has no clash afterwards. A palette shorter than the numbers dsatur chooses is refused.

Places and boxes are latitude and longitude in degrees, north and east positive. A box whose western edge lies east of its eastern one crosses the antimeridian:

import geography

geography.place("suva", -18.14, 178.44)
geography.place("nukualofa", -21.14, -175.2)
geography.place("oslo", 59.91, 10.75)
geography.place("tromso", 69.65, 18.96)
geography.box("fiji_tonga", -22.0, -12.0, 170.0, -173.0)
geography.box("south_norway", 57.9, 63.0, 4.5, 12.5)

print(geography.places_in("fiji_tonga"))
print(geography.places_in("south_norway"))

update(geography.Place("oslo").lat, 65.0, "second survey")
print(geography.unsettled())
print(geography.places_in("south_norway"))
update(geography.Place("oslo").lat, 59.91, "second survey")
print(geography.unsettled())

prints ['nukualofa', 'suva'], ['oslo'], ['place oslo lat'], ['oslo'] and []. Suva lies at 178.44 degrees east and Nuku’alofa at 175.2 west, on either side of the antimeridian, and both are in the box: with its edges at 170 and -173 it holds the longitudes from 170 round through 180 to -173. A second survey put Oslo at 65 degrees north, outside the box. Both values are reasoned about, so Oslo is still in the box, which the first value puts it in, and unsettled() names the place and the field. Writing the second survey as the first agrees, and nothing is named.

The calculations answer values and write nothing. On the sphere, with the Earth’s mean radius of 6371008.8 metres:

import geography

oslo = [59.91, 10.75]
bergen = [60.39, 5.32]
d = geography.haversine(oslo[0], oslo[1], bergen[0], bergen[1])
print(round(d / 1000, 1))
print(round(geography.bearing(oslo[0], oslo[1], bergen[0], bergen[1]), 1))
p = geography.destination(oslo[0], oslo[1], 270.0, 100000.0)
print(round(p[0], 4), round(p[1], 4))
print(round(geography.mercator(60.0), 6))
print(round(geography.mercator_scale(60.0), 6))
print(round(geography.triangle_area(0.0, 0.0, 0.0, 90.0, 90.0, 0.0) / 1000000, 1))

prints 305.1, 282.4, 59.8978 8.9567, 1.316958, 2.0 and 63758235.1. Bergen is 305.1 kilometres from Oslo along the great circle, in the direction 282.4 degrees clockwise from north, a little north of west. A hundred kilometres due west of Oslo, on the great circle, is 59.8978 north and 8.9567 east: a great circle that sets out due west bends toward the equator, so the latitude falls a little. The Mercator projection stretches a short distance by one over the cosine of its latitude, so at 60 degrees a distance is drawn twice as long. The triangle with corners on the equator at 0 and 90 degrees east and at the north pole is an eighth of the sphere, 63758235.1 square kilometres.

The Earth is not a sphere, and for a surveyor’s distance the ellipsoid is better. The example Vincenty published his method with is the line from Flinders Peak to Buninyong, in Victoria, Australia:

import geography

lat1 = -(37 + 57 / 60 + 3.7203 / 3600)
lon1 = 144 + 25 / 60 + 29.5244 / 3600
lat2 = -(37 + 39 / 60 + 10.1561 / 3600)
lon2 = 143 + 55 / 60 + 35.3839 / 3600
v = geography.vincenty_inverse(lat1, lon1, lat2, lon2)
print(round(v[0], 3), round(v[1], 4), round(v[2], 4), v[3])
there = geography.dms(v[1])
back = geography.dms(v[2])
print(there[0], there[1], round(there[2], 2))
print(back[0], back[1], round(back[2], 2))
print(round(geography.haversine(lat1, lon1, lat2, lon2), 1))
q = geography.vincenty_direct(lat1, lon1, v[1], v[0])
print(round(q[0] - lat2, 9), round(q[1] - lon2, 9))
print(geography.vincenty_inverse(0.0, 0.0, 0.5, 179.7))

prints 54972.271 306.8682 127.1736 4.0, 306.0 52.0 5.37, 127.0 10.0 25.07, 54925.5, 0.0 0.0 and [0.0, 0.0, 0.0, -1.0]. The distance is 54972.271 metres, and the azimuth from Flinders Peak to Buninyong is 306 degrees 52 minutes 5.37 seconds, with 127 degrees 10 minutes 25.07 seconds the azimuth from Buninyong back: the figures Vincenty gave. The iteration took four rounds. The sphere gives 54925.5 metres for the same line, 47 metres short. vincenty_direct goes the other way, from a point, an azimuth and a distance to the point reached, and sets out from Flinders Peak along the azimuth for the distance to land where Buninyong is, within a nanodegree. Two points almost opposite each other on the globe are the one case where the method has no answer: after 200 rounds it gives up and returns -1.0 as the number of rounds, with zeros for the rest.

What geography does not do is listed in the Library Reference: it does not decide that two regions touch, it does not choose the fewest colours a map needs, it knows no height above the sea, and it has no answer at the poles for the Mercator projection.

Chapter 20 — A thirteenth module: mathematics

mathematics reads three kinds of record. From whole numbers, each written with its candidate divisors, it concludes which candidates divide, the sum of each number’s proper divisors, and whether the number is prime or composite and perfect, abundant or deficient. From a binary operation on a finite set, given as its table, it concludes the identity, whether the operation is closed, commutative and associative, which elements have an inverse, and whether it is a group; where a property fails, it names the cells that break it. From a triangle’s three sides it concludes whether they close up into a triangle, and whether it is acute, right or obtuse. It also calculates: Euclid’s algorithm, modular arithmetic, primes, continued fractions and Pell’s equation, roots, integrals and linear systems. Version 1.0.2, at dke.langsyn.com/source/mathematics/latest/mathematics.dpy.

A number is written with a candidate divisor for each k from 1 to half of it. Write the numbers 1 to 30 and ask which are prime, perfect and abundant:

import mathematics

mathematics.numbers(1, 30)

print(mathematics.numbers_that_are("prime"))
print(mathematics.numbers_that_are("perfect"))
print(mathematics.numbers_that_are("abundant"))
print(len(mathematics.numbers_that_are("deficient")))
print(mathematics.divisors(28))
w = why(mathematics.Number.n4.composite)
for c in w:
    print(str(c.path) + " = " + str(c.value))

prints [2, 3, 5, 7, 11, 13, 17, 19, 23, 29], [6, 28], [12, 18, 20, 24, 30], 23 and [1, 2, 4, 7, 14], then the facts the last conclusion rests on, one to a line, from mathematics.Number.n4.composite = True down: the candidate 2 of the number 4, which names n4 as the number it is of, has k 2, divides, leaves a remainder of 0, and carries the number’s value, 4. A perfect number is the sum of its proper divisors, and 28 is 1 + 2 + 4 + 7 + 14; 12 is abundant, because its divisors 1, 2, 3, 4 and 6 sum to 16. The number 1 is neither prime nor composite and has no divisor sum, and is deficient, one of the 23 that are.

A finite operation is its table. Write addition and subtraction modulo 4, each as the four-by-four table of its products, and ask what holds of each:

import mathematics

els = ["0", "1", "2", "3"]
add = [[str((i + j) % 4) for j in range(4)] for i in range(4)]
sub = [[str((i - j) % 4) for j in range(4)] for i in range(4)]
mathematics.operation("z4", els, add)
mathematics.operation("minus4", els, sub)

print(mathematics.properties("z4"))
print(mathematics.properties("minus4"))
print(mathematics.identity_of("z4"))
print(mathematics.identity_of("minus4") == "")
print(mathematics.noncommuting_pairs("minus4"))
print(mathematics.nonassociative_triples("minus4")[0])

prints ['closed', 'commutative', 'associative', 'group', 'abelian'], ['closed'], 0, True, ['0 1', '0 3', '1 0', '1 2', '2 1', '2 3', '3 0', '3 2'] and 0 0 1. Addition modulo 4 is an abelian group with identity 0. Subtraction is closed and nothing more: 0 − 1 is 3 but 1 − 0 is 1, so the pairs named do not commute; (0 − 0) − 1 is 3 but 0 − (0 − 1) is 1, so the first triple named does not associate; and 0 is an identity from the right only, so the operation has none and identity_of answers "".

A table rewritten withdraws what its cells held up. Say the cell 2 + 3 is written as 0 instead of 1, then written back, then recorded again as 0 by a second source:

import mathematics

mathematics.cell("z4", "2", "3", "0")
print(mathematics.properties("z4"))
print(mathematics.noncommuting_pairs("z4"))

mathematics.cell("z4", "2", "3", "1")
print(mathematics.properties("z4"))

update(mathematics.Cell("z4_2_3").c, "0", "second table")
print(mathematics.unsettled())
print(mathematics.properties("z4"))

prints ['closed'] and ['2 3', '3 2'], then all five properties again, then ['cell z4_2_3 c'] and ['closed']. With 2 + 3 equal to 0, the cell no longer matches 3 + 2, so the pair is named and the group is gone. Writing the cell as it was under the same source replaced the first correction, and the properties came back. The second source’s claim is held beside it instead of replacing it, and both products are reasoned about: the operation is no longer commutative or a group because one value on record breaks them, and unsettled() names the cell. No query refuses.

A triangle is three whole lengths, and the module says what they make:

import mathematics

mathematics.triangle("egypt", 3, 4, 5)
mathematics.triangle("flat", 1, 2, 3)
mathematics.triangle("none", 1, 2, 5)
mathematics.triangle("wide", 4, 4, 7)
mathematics.triangle("even", 6, 6, 6)

print(mathematics.kinds("egypt"))
print(mathematics.kinds("flat"))
print(mathematics.kinds("none"))
print(mathematics.kinds("wide"))
print(mathematics.kinds("even"))
print(mathematics.area("egypt"))
print(mathematics.area("none"))
print(mathematics.area("even"))
w = why(mathematics.Triangle.egypt.right)
for c in w:
    print(str(c.path) + " = " + str(c.value))

prints ['right', 'scalene'], [], [], ['obtuse', 'isosceles'] and ['acute', 'equilateral', 'isosceles'], then 6.0, 0.0 and 15.588457268119896, then the facts the first conclusion rests on: mathematics.Triangle.egypt.right = True, then the triangle’s being valid, its slack 2, its sides 3, 4 and 5 and its gap 0. The gap, a² + b² − c², is 0 for a right triangle, positive for an acute one and negative for an obtuse one. Lengths 1, 2 and 3 lie flat, with a slack of 0, and 1, 2 and 5 never meet: neither is a triangle, so neither has a kind or an area. The area comes from Heron’s formula, on a whole number the module computes from the sides.

The calculations answer values and write nothing. First the arithmetic of divisibility:

import mathematics

print(mathematics.gcd(1071, 462), mathematics.lcm(21, 6))
print(mathematics.extended_gcd(240, 46))
print(mathematics.modinv(3, 11), mathematics.modinv(4, 8))
print(mathematics.modpow(2, 100, 1000000007))
print(mathematics.is_prime(2147483647), mathematics.is_prime(561))
print(mathematics.factorize(360), mathematics.factorize(600851475143))
print(mathematics.totient(36))
print(mathematics.primes(30))
print(mathematics.factorial(20), mathematics.binomial(52, 5))
print(mathematics.fibonacci(92), mathematics.collatz(27), mathematics.collatz(97))

prints 21 42, [2, -9, 47], 4 0, 976371285, True False, [2, 2, 2, 3, 3, 5] [71, 839, 1471, 6857], 12, the primes to 30, 2432902008176640000 2598960 and 7540113804746346429 111 118. The extended algorithm gives 240 · (−9) + 46 · 47 = 2, the greatest common divisor. The inverse of 3 modulo 11 is 4; 4 has none modulo 8, and the answer is 0. The number 561 is a Carmichael number, which fools the simple Fermat test, and the Miller–Rabin test the module runs finds it composite. A whole number is 64 bits: 20! is computed and 21! is refused rather than wrapped, and so is the 93rd Fibonacci number, while the 92nd is the largest.

Then continued fractions and Pell’s equation:

import mathematics

cf = mathematics.continued_fraction(415, 93)
print(cf)
print(mathematics.convergents(cf))
print(mathematics.sqrt_period(23))
print(mathematics.pell(61))
print(mathematics.root(2.0, 2), mathematics.root(27.0, 3))
print(mathematics.isqrt(1000000))

prints [4, 2, 6, 7], [[4, 1], [9, 2], [58, 13], [415, 93]], [4, 1, 3, 1, 8], [1766319049, 226153980], 1.414213562373095 3.0 and 1000. The fraction 415/93 is 4 + 1/(2 + 1/(6 + 1/7)), and its last convergent is the fraction itself. The square root of 23 is 4 followed by 1, 3, 1 and 8 repeating forever. The smallest solution of x² − 61y² = 1 is a ten-digit x and a nine-digit y, which pell finds from that repeating part.

And numerical methods:

import mathematics

ys = [0.0, 0.25, 1.0, 2.25, 4.0]
print(mathematics.trapezoid(ys, 0.5))
print(mathematics.simpson(ys, 0.5))
print(mathematics.machin())
m = [[2.0, 1.0], [1.0, 3.0]]
print(mathematics.determinant(m))
print(mathematics.solve(m, [3.0, 5.0]))
print(mathematics.determinant(mathematics.hilbert(3)))
print(mathematics.solve([[1.0, 2.0], [2.0, 4.0]], [1.0, 2.0]))

prints 2.75, 2.6666666666666665, 3.1415926535897936, 5.0, [0.8, 1.4], 0.00046296296296296135 and []. The samples are x² at 0, 0.5, 1, 1.5 and 2; Simpson’s rule gets the integral, 8/3, to the last digit and the trapezoidal rule overshoots it. The determinant of the third Hilbert matrix is exactly 1/2160, and the figure printed is that with the rounding elimination leaves. The last system is singular, so it has no solution to give.

What mathematics does not check is listed in the Library Reference: that a table is the operation it was meant to be, and whether a number was written with the candidates it needs.

Chapter 21 — A fourteenth module: statistics

statistics reads three kinds of record. From the strata of a study, in each of which two arms were tried, it concludes each arm’s rate in each stratum and in the pool, and names a study whose pooled comparison reverses what every stratum shows. From a screening test, by how common the condition is and how good the test is, it concludes what a positive result is worth. From a significance test, by its p-value and the level it is held to, it concludes whether the result is significant. It also calculates what a statistician calculates: the mean, the variance, a least-squares line, the normal, binomial and Poisson distributions, the chi-square statistic and a seeded generator. Version 1.0.3, at dke.langsyn.com/source/statistics/latest/statistics.dpy.

A study is strata that name it. Each stratum gives the successes and the trials of arm A and of arm B. The counts below are those of a study of two treatments for kidney stones, divided by the size of the stone:

import statistics

statistics.stratum("stones", "small", 81, 87, 234, 270)
statistics.stratum("stones", "large", 192, 263, 55, 80)

print(statistics.paradoxes())
print(statistics.pooled("stones"))
a = current(statistics.Stratum.stones_small.a_rate)
b = current(statistics.Stratum.stones_small.b_rate)
print(a.value, b.value)
c = current(statistics.Stratum.stones_large.a_rate)
d = current(statistics.Stratum.stones_large.b_rate)
print(c.value, d.value)

prints ['stones'], [0.78, 0.8257142857142857], 0.9310344827586207 0.8666666666666667 and 0.7300380228136882 0.6875. Arm A succeeded in 93% of small stones against 87%, and in 73% of large stones against 69%: it is ahead in both strata. Pooled, arm B is ahead, 83% against 78%, because arm B was tried mostly on the small stones, which are the easy ones. That reversal is Simpson’s paradox, and the module names the study. Which comparison to believe is yours to decide: the counts do not say why the arms were given the stones they were.

A conclusion withdraws when the count it rests on is corrected. Say arm B’s count in the large stratum was recorded wrongly and is 70, not 55; then say a second source gives a different count for a small one:

import statistics

statistics.stratum("stones", "small", 81, 87, 234, 270)
statistics.stratum("stones", "large", 192, 263, 55, 80)
print(statistics.paradoxes())

statistics.stratum("stones", "large", 192, 263, 70, 80)
print(statistics.paradoxes())
print(statistics.pooled("stones"))

update(statistics.Stratum("stones_small").a_yes, 85, "audit")
print(statistics.unsettled())
print(statistics.disputed_studies())

prints ['stones'], [], [0.78, 0.8685714285714285], ['stratum stones_small a_yes'] and ['stones']. With 70 of 80, arm B is ahead in the large stratum too, so the pool no longer reverses anything and the paradox is gone. The audit gave the small stratum a second count for arm A, 85 where the first source said 81, and unsettled() names the stratum and the field. A pooled count is a sum, which would add both, 85 and 81, into a pool no source gives, so arm A’s pooled successes are disputed and not pooled, and pooled() refuses the study until one value is forgotten. The other three counts, which both sources agree on, are still pooled.

A screening test is judged by what a positive result means, and that depends on how common the condition is as much as on the test:

import statistics

statistics.screen("s1", 0.01, 0.9, 0.9)
statistics.screen("s2", 0.2, 0.9, 0.9)
print(statistics.predictive_values("s1"))
print(statistics.predictive_values("s2"))
m = current(statistics.Screen.s1.mostly_false)
n = current(statistics.Screen.s2.mostly_false)
print(m.value, n.value)

statistics.significance("t1", 0.049, 0.05)
statistics.significance("t2", 0.05, 0.05)
statistics.significance("t3", 0.2, 0.05)
print(statistics.significant_tests())

prints [0.08333333333333336, 0.9988789237668162], [0.6923076923076924, 0.9729729729729729], True False and ['t1']. Both tests find 90% of those who have the condition and clear 90% of those who do not. Where one person in a hundred has it, only 8% of the positive results are right, and a positive result is more likely wrong than right. Where one in five has it, 69% are. significant is a comparison of two numbers you wrote: 0.049 is below 0.05, and 0.05 is not below itself.

The calculations answer values and write nothing. Anscombe’s quartet is four sets of eleven points that share a mean and a line; here is the first, with its descriptive statistics, and then two samples:

import statistics

x = [10.0, 8.0, 13.0, 9.0, 11.0, 14.0, 6.0, 4.0, 12.0, 7.0, 5.0]
y = [8.04, 6.95, 7.58, 8.81, 8.33, 9.96, 7.24, 4.26, 10.84, 4.82, 5.68]
print(statistics.mean(x), statistics.median(x))
print(statistics.variance(x), statistics.std_dev(x))
print(statistics.regression(x, y))
print(statistics.mean([1.0, 2.0, 3.0, 4.0]), statistics.median([1.0, 2.0, 3.0, 4.0]))
a = [27.5, 21.0, 19.0, 23.6, 17.0, 17.9, 16.9, 20.1, 21.9, 22.6, 23.1, 19.6, 19.0, 21.7, 21.4]
b = [27.1, 22.0, 20.8, 23.4, 23.4, 23.5, 25.8, 22.0, 24.8, 20.2, 21.9, 22.1, 22.9, 20.5, 24.4]
print(statistics.welch_t(a, b))

prints 9.0 9.0, 10.999999999999996 3.3166247903553994, [3.0000909090909103, 0.5000909090909091, 0.81642051634484], 2.5 2.5 and -2.4553563982860105. The line through the points is y = 3.0001 + 0.5001x with a correlation of 0.8164, which rounds to the line Anscombe published for all four of his sets, y = 3.00 + 0.500x with r = 0.816. The variance is 11, and prints as 10.999999999999996 because the sum of squares is taken in a single pass and carries a rounding. Welch’s t for the two samples is -2.455.

Distributions are functions of numbers:

import statistics

print(statistics.normal_cdf(0.0), statistics.normal_cdf(-5.0))
print(statistics.normal_quantile(0.975))
print(statistics.birthday(23))
print(statistics.binom_pmf(10, 3, 0.5), statistics.binom_cdf(10, 3, 0.5))
print(statistics.poisson_pmf(2, 3.0))
print(statistics.bayes(0.01, 0.9, 0.1))
print(statistics.chi_square_p_df1(3.841458820694124))
print(statistics.monte_carlo_pi(10000, 1))

prints 0.5 2.8665157187919455e-07, 1.9599639845400532, 0.5072972343239855, 0.1171875 0.171875, 0.22404180765538773, 0.08333333333333333, 0.05000000000000007 and 3.1344. Five standard deviations below the mean leaves 2.9 in ten million, with its digits, since the tail is not computed as one minus the body. The z that cuts off the upper 2.5% is 1.96. Twenty-three people give a better than even chance that two share a birthday. Three heads in ten tosses has a probability of 0.117, and three or fewer 0.172. A chi-square statistic of 3.84 with one degree of freedom is the 5% level. The last answer is the generator’s: the module carries its own, since DKE Python has no random module, and 10,000 points from seed 1 give 3.1344 every time. A service bounds the memory one run may hold, and the generator’s points count against it, so the number of points is for the thousands: 20,000 is more than a run on the service holds. A seed of 0 stays 0.

What statistics does not do is listed in the Library Reference: it does not compute a p-value from data, adjust it for many tests, or say whether an effect matters, and it does not check that a probability lies between 0 and 1.

Chapter 22 — A fifteenth module: earthscience

earthscience reads three kinds of record. From weather stations, by their temperatures and rainfall, it concludes each one’s Köppen group. From storms, by their wind, it concludes whether each is a hurricane and its category. From minerals, by their hardness, it concludes which scratches which. It also calculates what a meteorologist and a geophysicist calculate: the dew point, the heat index, the wind chill, the standard atmosphere, the age of a rock and the energy of an earthquake. Version 1.0.2, at dke.langsyn.com/source/earthscience/latest/earthscience.dpy.

A station is written with six numbers: the mean temperature of its coldest and of its warmest month in degrees Celsius, its annual mean temperature, its annual rainfall in millimetres, the fraction of that rain which falls in the half of the year when the sun is high, and the temperature, 0.0 or -3.0, that divides the temperate group from the continental. Record seven places and ask for each one’s group and kind:

import earthscience

earthscience.station("manaus", 26.0, 28.0, 27.0, 2300.0, 0.5, 0.0)
earthscience.station("cairo", 14.0, 29.0, 22.0, 25.0, 0.5, 0.0)
earthscience.station("oslo", -4.0, 17.0, 6.0, 760.0, 0.5, 0.0)
earthscience.station("tromso", -3.0, 12.0, 3.0, 1030.0, 0.5, -3.0)
earthscience.station("barrow", -26.0, 4.0, -12.0, 120.0, 0.5, 0.0)
earthscience.station("vostok", -65.0, -32.0, -55.0, 20.0, 0.5, 0.0)
earthscience.station("madrid", 6.0, 25.0, 14.0, 360.0, 0.3, 0.0)
for s in ["manaus", "cairo", "oslo", "tromso", "barrow", "vostok", "madrid"]:
    print(s, earthscience.groups(s), earthscience.climate(s), earthscience.dryness_threshold(s))
n = why_not(earthscience.Station.cairo.tropical)
print(n)

prints, one to a line, manaus ['A'] A 680.0, cairo ['B'] BW 580.0, oslo ['D'] D 260.0, tromso ['D'] D 200.0, barrow ['E'] ET -100.0, vostok ['E'] EF -960.0 and madrid ['B'] BS 420.0, then rule `earthscience.tropical` needs earthscience.Station[cairo].dry != True, and earthscience.Station[cairo].dry is True (bool). Manaus has a coldest month of 26 degrees: tropical. Cairo’s 25 millimetres is far under its threshold of 580, which is twenty times its mean of 22 degrees plus 140 for an even year, and under half of it, so it is a desert, and why_not says it is not tropical because it is dry. Madrid, with 360 millimetres against a threshold of 420, is dry, and above half of the threshold, 210, so it is a steppe. The two polar stations are told apart by their warmest month, Barrow’s above 0 degrees a tundra and Vostok’s below it an ice cap. The threshold is negative for both, since it depends on a temperature that is, and neither is arid because they are polar first.

The version of the system is yours to say, and a correction replaces the old answer:

import earthscience

earthscience.station("tromso", -1.5, 12.0, 3.0, 1030.0, 0.5, 0.0)
print(earthscience.climate("tromso"))
earthscience.station("tromso", -1.5, 12.0, 3.0, 1030.0, 0.5, -3.0)
print(earthscience.climate("tromso"))
earthscience.station("cairo", 14.0, 29.0, 22.0, 25.0, 0.5, 0.0)
print(earthscience.climate("cairo"))
earthscience.station("cairo", 14.0, 29.0, 22.0, 900.0, 0.5, 0.0)
print(earthscience.climate("cairo"), earthscience.dryness_threshold("cairo"))
update(earthscience.Station("cairo").rain, 25.0, "second survey")
print(earthscience.climate("cairo"), earthscience.groups("cairo"))
print(earthscience.unsettled())

prints D, C, BW, C 580.0, BW ['B', 'C'] and ['station cairo rain']. A station whose coldest month is -1.5 degrees is continental where the temperate group stops at 0 and temperate where it stops at -3. Writing Tromsø again with the second version replaced the first. Cairo with 25 millimetres of rain is a desert; written again with 900 it is temperate, its dryness is gone, and the threshold stays 580. A second survey put the 25 back, and both values are reasoned about: Cairo is in two groups, B and C, groups names both and climate reads the first, and unsettled() names the station and the field.

Storms and minerals are simpler:

import earthscience

earthscience.storm("a", 73)
earthscience.storm("b", 74)
earthscience.storm("c", 111)
earthscience.storm("d", 157)
for s in ["a", "b", "c", "d"]:
    print(s, earthscience.category(s))
h = current(earthscience.Storm.c.major)
print(h.value)
w = why_not(earthscience.Storm.a.hurricane)
print(w)
earthscience.minerals(["quartz", "calcite", "topaz", "orthoclase"], [7.0, 3.0, 8.0, 6.0])
print(earthscience.scratched_by("quartz"), earthscience.scratches("quartz", "topaz"))
print(earthscience.scratched_by("topaz"))

prints a 0, b 1, c 3, d 5, True, rule `earthscience.hurricane` needs earthscience.Storm[a].wind_mph >= 74, and earthscience.Storm[a].wind_mph is 73 (int), ['calcite', 'orthoclase'] False and ['calcite', 'orthoclase', 'quartz']. A wind of 73 miles per hour is not a hurricane and has no category; at 74 it is category 1, at 111 a major hurricane of category 3, and at 157 category 5. Quartz scratches calcite and orthoclase and not topaz, which is harder; topaz scratches all three.

The calculations answer values and write nothing:

import earthscience

print(earthscience.dew_point(30.0, 70.0))
print(earthscience.heat_index(90.0, 70.0))
print(earthscience.wind_chill(10.0, 20.0))
print(earthscience.pressure_at(0.0), earthscience.pressure_at(5000.0))
print(earthscience.coriolis(59.91))
print(earthscience.lcl_height(30.0, 22.0))
print(earthscience.radiometric_age(1.0, 4.463e9))
print(earthscience.quake_energy_joules(6.0))
print(earthscience.magnitude_from_energy(earthscience.quake_energy_joules(6.0)))
print(earthscience.quake_ratio(6.0, 7.0), earthscience.quake_ratio(5.0, 7.0))
print(earthscience.sp_distance_km(10.0, 8.0, 4.5))

prints 23.930467834837106, 105.92202060000027, -8.876220556825402, 1013.25 540.0484237684415, 0.00012618817712699152, 1000.0, 4463000000.0, 63095734448019.43, 6.0, 31.622776601683793 1000.0 and 102.85714285714286. Air at 30 degrees Celsius and 70% humidity has a dew point of 23.9 degrees, and air at 90 degrees Fahrenheit and 70% humidity feels like 106. A wind of 20 miles per hour at 10 degrees Fahrenheit chills to -8.9. The standard atmosphere’s pressure falls from 1013.25 hectopascals at sea level to 540 at 5,000 metres, about half. At 59.91 degrees of latitude the Coriolis parameter is 0.000126 per second. A sample with as many daughter atoms as parent atoms is one half-life old, here that of uranium-238, 4.463 billion years. An earthquake of magnitude 6 releases 6.3e13 joules, and one more step of magnitude 31.6 times as much; two steps, a thousand. A gap of 10 seconds between the P and the S wave, at 8 and 4.5 kilometres a second, is 102.9 kilometres.

What earthscience does not do is listed in the Library Reference: it does not conclude the finer climates inside a group, it reads no hemisphere, and it checks no argument against the range its formula was fitted for.

Chapter 23 — A sixteenth module: computing

computing reads one kind of record. From the words of a binary code it concludes the Kraft sum of the code, which of its words begin other words, and whether the code is prefix-free, so that a received string can be read without a separator. It also calculates what an information theorist and a programmer calculate: checksums and check digits, numbers in any base, an error-correcting code, entropy, Huffman code lengths, binary search and shortest paths. Version 1.0.1, at dke.langsyn.com/source/computing/latest/computing.dpy.

A code is written as its words, each a string of 0 and 1. Two codes of four and three words show what a Kraft sum does and does not say:

import computing

computing.code("good", ["0", "10", "110", "111"])
computing.code("bad", ["0", "01", "11"])
print(computing.prefix_free_codes())
print(computing.kraft_sum("good"), computing.kraft_sum("bad"))
print(computing.breaking_pairs("bad"))
k = current(computing.Code.bad.complete)
print(k.value)
w = why_not(computing.Code.bad.prefix_free)
print(w)
computing.code("short", ["0", "1", "10"])
print(computing.kraft_sum("short"))
o = current(computing.Code.short.oversubscribed)
print(o.value)

prints ['good'], 1.0 1.0, ['0 01'], True, rule `computing.prefix_free` needs computing.Code[bad].prefix_free__none1 to be absent, and it is present, 1.25 and True. The Kraft sum of a code is the sum of 2 to the minus the length of each word, and 1 is the most a code that can be read without a separator can have. Both codes have exactly 1, and only the first can be read without one: in the second, 0 is the beginning of 01, and a received 01 could be a 01 or a 0 followed by a 1. The sum is a property of the lengths and says only that a code of those lengths could exist. breaking_pairs names the pair that breaks the property, and why_not says that a pair does. The third code has three words of lengths 1, 1 and 2 and a sum of 1.25: over 1, no code that can be read without a separator has such words, and oversubscribed says so.

The calculations answer values and write nothing. First the checks that guard data in transit and in print, and numbers in other bases:

import computing

print(computing.crc32("123456789"), computing.to_base(computing.crc32("123456789"), 16))
print(computing.adler32("Wikipedia"), computing.to_base(computing.adler32("Wikipedia"), 16))
print(computing.luhn_valid("17893729974"), computing.luhn_valid("17893729975"))
print(computing.isbn13_valid("9780306406157"), computing.isbn13_valid("9780306406158"))
print(computing.to_base(255, 2), computing.to_base(255, 16), computing.to_base(123456789, 36))
print(computing.from_base("ff", 16), computing.from_base("21i3v9", 36))
w = computing.hamming_encode(11)
print(w)
print(computing.hamming_decode(w), computing.hamming_decode(w ^ 4), computing.hamming_decode(w ^ 64))
print(computing.hamming_distance(11, 4), computing.hamming_distance(w, w ^ 4))

prints 3421780262 cbf43926, 300286872 11e60398, True False, True False, 11111111 ff 21i3v9, 255 123456789, 51, 11 11 11 and 4 1. The CRC-32 of the nine characters 123456789 is the check value 0xcbf43926 that the catalogue of CRCs gives, and the Adler-32 of Wikipedia is the 0x11e60398 its article gives. A single changed digit fails Luhn’s check and the ISBN-13 check. Hamming(7,4) encodes the four bits of 11 as the seven-bit word 51; flip any one bit, here the third from the right and then the first from the left, and the decoder still answers 11. Two flipped bits would be decoded wrongly, since the code cannot tell them from one. The Hamming distance between 11 and 4 is 4 bits, and between the word and the one with a bit flipped, 1.

Then information and algorithms:

import computing

print(computing.entropy([1, 1]), computing.entropy([1, 1, 1, 1]), computing.entropy([3, 1]))
print(computing.huffman_lengths([45, 13, 12, 16, 9, 5]))
print(computing.binary_search([1, 3, 5, 7, 9, 11], 7), computing.binary_search([1, 3, 5, 7, 9, 11], 4))
print(computing.binary_search_steps(1000, 0), computing.binary_search_steps(1000000, 999999))
edges = [[0, 1, 7], [0, 2, 9], [0, 5, 14], [1, 2, 10], [1, 3, 15], [2, 3, 11], [2, 5, 2], [3, 4, 6], [5, 4, 9]]
print(computing.dijkstra(6, edges, 0))
print(computing.dijkstra(7, edges, 0))

prints 1.0 2.0 0.8112781244591328, [1, 3, 3, 3, 4, 4], 3 -1, 9 20, [0, 7, 9, 20, 20, 11] and [0, 7, 9, 20, 20, 11, 1000000]. Two equally likely symbols carry one bit and four carry two; a source that says one thing three times in four carries 0.81. The symbols of frequencies 45, 13, 12, 16, 9 and 5 get codewords of 1, 3, 3, 3, 4 and 4 bits in a Huffman code. A binary search finds the 7 at index 3 and reports -1 for the 4 that is not there; it takes 9 probes to find the first of a thousand and 20 to find the last of a million. In the graph, the shortest distance from node 0 to node 4 is 20, by way of 2 and 5. A seventh node that no edge reaches keeps the distance 1000000, which is how the script says it was not reached.

What computing does not do is listed in the Library Reference: it does not check that a word is a string of 0 and 1, it holds numbers only to the 63 binary digits of an int, and its shortest paths need weights from 0 up.

Chapter 24 — A seventeenth module: calendars

calendars reads three kinds of record. From the years you name it concludes which are leap years under the Julian rule and under the Gregorian rule, and which the two disagree about. From a calendar reform it counts the days the reform skipped, and from the days you name it marks those that fall in the gap, days a place never had. It also calculates what a historian and a programmer calculate: the length of a month, whether a date exists, the weekday of a date, Easter, day numbers and the span between two instants. Version 1.0.0, at dke.langsyn.com/source/calendars/latest/calendars.dpy.

A year is written by its number, and the module concludes the rest. Eleven years around 1900 show the two rules part:

import calendars

calendars.years(1895, 1905)
print(calendars.years_that_differ())
print(calendars.julian_leap(1900), calendars.gregorian_leap(1900), calendars.gregorian_leap(1904))
print(calendars.differ(1900), calendars.differ(1904))
n = why_not(calendars.Year.y1900.gregorian_leap)
print(n)
print(calendars.gregorian_leap(2000))

prints [1900], True False True, True False, two lines for the why_not and False. The Julian rule makes every fourth year a leap year, so 1900 is one, and the Gregorian rule makes it one only if it is not a century, or if it divides by 400, so 1900 is not. It is the one year in the eleven the rules disagree about, and differ says so. The why_not of 1900 names both ways it could have been a leap year and why each failed: rule `calendars.gregorian_leap_fourth_century` needs calendars.Year[y1900].r400 == 0, and calendars.Year[y1900].r400 is 300 (int) and rule `calendars.gregorian_leap_ordinary` needs calendars.Year[y1900].r100 != 0, and calendars.Year[y1900].r100 is 0 (int). The last line is a trap worth seeing once: 2000 is a Gregorian leap year, but this store holds no year 2000, so the rules have concluded nothing about it and the answer is False. The calculations, which come last, answer for any year.

A reform is the last day of the old calendar in a place and the first of the new, written as year, month and day, with the source that says so. A day is written the same way. Rome’s reform and Britain’s, and three days:

import calendars

calendars.reform("rome", 1582, 10, 4, 1582, 10, 15, "Inter gravissimas")
calendars.reform("britain", 1752, 9, 2, 1752, 9, 14, "Calendar (New Style) Act 1750")
calendars.day("d1", 1582, 10, 10)
calendars.day("d2", 1582, 10, 20)
calendars.day("d3", 1752, 9, 10)
print(calendars.skipped("rome"), calendars.skipped("britain"))
print(calendars.missing("d1", "rome"), calendars.missing("d2", "rome"), calendars.missing("d1", "britain"))
print(calendars.missing("d3", "britain"), calendars.missing("d3", "rome"))

prints [10] [11], True False False and True False. Rome went from Thursday 4 October 1582 to Friday 15 October and skipped 10 days; Britain went from 2 September 1752 to 14 September and skipped 11. The tenth of October 1582 is in Rome’s gap, a day Rome never had, and the twentieth is not. The same day is not in Britain’s, which still kept the old calendar, and the tenth of September 1752 is in Britain’s gap and not in Rome’s. The module marks a day that falls in a gap and keeps it: the day is a fact, and the gap is a conclusion about it.

A correction withdraws what rested on it, and a rival reading is kept beside the first:

import calendars

calendars.day("d5", 1752, 9, 14)
calendars.reform("britain", 1752, 9, 2, 1752, 9, 15, "Calendar (New Style) Act 1750")
print(calendars.skipped("britain"), calendars.missing("d5", "britain"))
calendars.reform("britain", 1752, 9, 2, 1752, 9, 14, "Calendar (New Style) Act 1750")
print(calendars.skipped("britain"), calendars.missing("d5", "britain"))
calendars.reform("rome", 1582, 10, 4, 1582, 10, 16, "a rival reading")
print(calendars.skipped("rome"))
print(calendars.unsettled())
calendars.day("d4", 1582, 10, 15)
print(calendars.missing("d4", "rome"))

prints [12] True, [11] False, [10, 11], ['reform rome first'] and True. Britain’s reform is first written with a first day of 15 September, by mistake, and the 14th is then in the gap and 12 days are skipped. Writing the reform again under the same source replaces the value, the skipped days go back to 11, and the 14th is out of the gap: nothing that rested on the mistake remains. The rival reading of Rome’s reform is written under another source, so nothing is replaced. Both values are reasoned about, skipped gives both counts, unsettled() names the disagreement, and the 15th of October is in the gap under the rival value, so missing says so. No answer refuses.

The calculations answer values and write nothing:

import calendars

print(calendars.month_length(1900, 2), calendars.month_length(2000, 2), calendars.julian_month_length(1900, 2))
print(calendars.date_exists(2026, 2, 29), calendars.date_exists(2028, 2, 29))
print(calendars.weekday(2026, 11, 9), calendars.weekday(1582, 10, 15))
print(calendars.easter(2026), calendars.easter(2000))
print(calendars.day_number(2000, 1, 1) - calendars.day_number(1900, 1, 1))
print(calendars.julian_day_number(1582, 10, 4), calendars.julian_day_number(1582, 10, 5))
a = datetime("2026-11-09T12:00:00Z")
b = datetime("2028-03-01T00:00:00Z")
print(calendars.span(a, b), calendars.after(a, 100))

prints 28 29 29, False True, 1 5, [4, 5] [4, 23], 36524, 577737 577738 and P477DT12H 2027-02-17T12:00:00Z. February 1900 has 28 days under the Gregorian rule and 29 under the Julian, and February 2000 has 29. 29 February 2026 does not exist and 2028’s does. The weekday is a number from 0 for Sunday, so 9 November 2026 is a Monday and 15 October 1582 a Friday, counted in the proleptic Gregorian calendar. Easter Sunday is 5 April in 2026 and 23 April in 2000. The days from 1 January 1900 to 1 January 2000 are 36524, a century of 365-day years with 24 leap days. The Julian day numbers of 4 and 5 October 1582 are consecutive, which is how the module counts the days a reform skipped. The span from noon on 9 November 2026 to the start of 1 March 2028 is 477 days and 12 hours, and a hundred days after the first is 17 February 2027.

What calendars does not do is listed in the Library Reference: it counts dates in the proleptic Gregorian calendar and a datetime in years 1 to 9999, a leap second has no place in it, span refuses a time of more than about 292 years, and it does not check that a date given as three integers exists unless you ask with date_exists.

Chapter 25 — An eighteenth module: colour

colour reads two kinds of record. From the relative luminance of a text and of its background it concludes which of the four contrast levels of the Web Content Accessibility Guidelines the pair clears. From the refractive indices on either side of a boundary and the sine of the angle at which a ray meets it, it concludes whether the ray is totally reflected. It also calculates what a designer and a physicist calculate: relative luminance, the contrast ratio, hue, saturation and lightness, Snell’s law, the critical angle, the thin lens, Wien’s law and the frequency of a wavelength. Version 1.0.1, at dke.langsyn.com/source/colour/latest/colour.dpy.

A pair is written as the luminance of its text and of its background, and luminance_hex gives one from a colour written #rrggbb:

import colour

colour.pair("grey", colour.luminance_hex("#777777"), 1.0)
colour.pair("ink", colour.luminance_hex("#000000"), 1.0)
colour.pair("white_on_blue", 1.0, colour.luminance_hex("#0000ee"))
print(colour.levels("grey"), colour.levels("ink"), colour.levels("white_on_blue"))
print(colour.contrast_hex("#777777", "#ffffff"))
n = why_not(colour.Pair.grey.aa)
print(n)
colour.pair("grey", colour.luminance_hex("#595959"), 1.0)
print(colour.levels("grey"))
update(colour.Pair.grey.fg, 0.3, "a second reading")
print(colour.unsettled(), colour.levels("grey"))

prints ['large AA'] ['AA', 'AAA', 'large AA', 'large AAA'] ['AA', 'AAA', 'large AA', 'large AAA'], 4.478089453577214, two lines for the why_not, ['AA', 'AAA', 'large AA', 'large AAA'] and ['pair grey fg'] ['AA', 'AAA', 'large AA', 'large AAA']. Grey #777777 on white has a contrast ratio of 4.478, just under the 4.5 that AA asks, so it clears only the level for large text. The why_not of aa names the two ways a pair could have cleared it and what failed: rule `colour.aa_dark_text` needs colour.Pair[grey].inverse >= 4.5, and colour.Pair[grey].inverse is 4.478089453577214 (real) and rule `colour.aa_light_text` needs colour.Pair[grey].fg > colour.Pair[grey].bg, and colour.Pair[grey].fg is 0.184474994500441 (real). Black on white clears all four, as does white on #0000ee, a blue. Writing the grey pair again with the darker #595959 replaces the first value, and it clears all four. A second source gives the text a luminance of 0.3. Both values are reasoned about: unsettled() names the fact, and the pair clears the levels the darker value clears, because a conclusion stands if any value on record supports it.

A ray is written as the index it leaves, the index it meets and the sine of its angle of incidence:

import colour

colour.ray("glass_air", 1.52, 1.0, 0.8)
colour.ray("glass_air_shallow", 1.52, 1.0, 0.5)
colour.ray("air_glass", 1.0, 1.52, 0.99)
print(colour.total("glass_air"), colour.total("glass_air_shallow"), colour.total("air_glass"))
n = why_not(colour.Ray.glass_air_shallow.total)
print(n)
print(colour.critical_angle(1.52, 1.0), colour.critical_angle(2.417, 1.0))
print(colour.refraction_angle(1.0, 1.333, 30.0), colour.refraction_angle(1.52, 1.0, 30.0))

prints True False False, rule `colour.total_internal_reflection` needs colour.Ray[glass_air_shallow].sin_t > 1.0, and colour.Ray[glass_air_shallow].sin_t is 0.76 (real), 41.139510414899156 24.439743865451863 and 22.030108725578987 49.46419788868344. Light in glass at a sine of 0.8 meets air at a refracted sine of 1.216, which no angle has, so it is totally reflected. At a sine of 0.5 the refracted sine is 0.76 and the ray escapes, and the why_not says so. A ray going into glass is never totally reflected. The critical angle for glass into air is 41.1 degrees and for diamond into air 24.4, the small angle that lets a cut diamond hold light. Light in air meeting water at 30 degrees refracts to 22.0, and in glass meeting air at 30 degrees to 49.5.

The calculations answer values and write nothing:

import colour

print(colour.luminance(255, 255, 255), colour.luminance(255, 0, 0), colour.luminance(0, 128, 0))
print(colour.contrast_ratio(1.0, 0.0), colour.contrast_ratio(0.0, 1.0))
print(colour.hsl(255, 0, 0), colour.hsl(0, 128, 0), colour.hsl(128, 128, 128))
print(colour.lens_image(10.0, 30.0), colour.lens_magnification(10.0, 30.0), colour.lens_image(10.0, 5.0))
print(colour.wien_peak(5778.0), colour.wien_peak(300.0))
print(colour.frequency_hz(500.0), colour.frequency_hz(700.0))

prints 1.0 0.2126 0.15438342968146074, 21.0 21.0, [0.0, 1.0, 0.5] [120.0, 1.0, 0.25098039215686274] [0.0, 0.0, 0.5019607843137255], 14.999999999999996 -0.5 -10.0, 5.015181645898235e-07 9.65923985e-06 and 599584915999999.9 428274939999999.94. White has a luminance of 1.0, pure red 0.2126, the weight of red in the formula, and a mid green 0.154. The contrast ratio is the same whichever of the two is lighter, and 21 for black and white. Pure red has hue 0, saturation 1 and lightness one half; the green is at 120 degrees; a grey has no hue and no saturation. A converging lens of focal length 10 with an object at 30 forms a real image at 15, half the size of the object and inverted, so the magnification is a negative one half, exactly, since it is taken as f / (f - u) rather than from the image distance; the last digits of 15 are those of the floating point, as in every calculation here. An object at 5, inside the focus, gives an image at -10, on the same side: a virtual one. The Sun’s radiation peaks near 5.0e-07 metres, 500 nanometres, and a room at 300 kelvin near 9.7e-06, about nineteen times longer. A wavelength of 500 nanometres is a frequency of about 6.0e14 hertz and 700 nanometres 4.3e14.

Some calls refuse where no value exists. colour.critical_angle(1.0, 1.52), into a denser medium, refuses with a math domain error, as does refraction_angle for a ray that is totally reflected. lens_image with the object at the focus refuses with a division by zero, and luminance_hex of a string that is not # and six hexadecimal digits refuses and says how a colour is written.

What colour does not do is listed in the Library Reference: it covers sRGB and no other colour space, the saturation it computes is the HSL model’s, it has no spectra, the sign conventions of a diverging lens are outside it, and a refractive index does not vary with wavelength.

Chapter 26 — A nineteenth module: body

body is not medical advice. It states rules taught in every introduction to the subject and arithmetic with published formulas. Blood compatibility in practice is decided by laboratory testing, a body mass index is a measure for populations that its own namer judged inappropriate for individual evaluation, and the energy and heart-rate formulas are estimates that may deviate significantly from a person’s actual value. It gives no dose, no diagnosis and no treatment. Ask a clinician. Nothing in this chapter is a recommendation for anyone’s body.

body reads two kinds of record. From the blood group of a donor and of a recipient it concludes which of the antigens A, B and D the donor’s red cells carry and the recipient’s do not, and from that whether red cells of the donor would react, from a lacked A or B, and whether they put the recipient at risk of sensitisation, from a lacked D. From a person’s mass and height it concludes the World Health Organization’s bands of body mass index. It also calculates the index, a resting energy, a body surface and the maximum heart rate by two formulas. Version 1.0.2, at dke.langsyn.com/source/body/latest/body.dpy.

A transfusion is written as two groups, with the source that says so:

import body

body.transfusion("t1", "O-", "AB+", "ward")
body.transfusion("t2", "AB+", "O-", "ward")
body.transfusion("t3", "O-", "O+", "ward")
body.transfusion("t4", "O+", "O-", "ward")
body.transfusion("t5", "A+", "B+", "ward")
print(body.verdict("t1"), body.verdict("t2"), body.verdict("t3"))
print(body.verdict("t4"), body.verdict("t5"))
print(body.lacks("t2"), body.lacks("t4"), body.lacks("t5"))
n = why_not(body.Transfusion.t1.reacts)
print(n)
print(body.antigens("AB+"), body.antigens("O-"))
update(body.Transfusion.t4.rd, 1, "a second reading")
print(body.unsettled(), body.verdict("t4"))

prints [] ['reacts', 'sensitises'] [], ['sensitises'] ['reacts'], ['A', 'B', 'D'] ['D'] ['A'], two lines for the why_not, [1, 1, 1] [0, 0, 0] and ['transfusion t4 rd'] ['sensitises']. Red cells of group O negative carry none of the three antigens, so given to an AB positive recipient nothing is lacked and nothing is concluded: the verdict is empty, and the why_not of reacts says that both of the premises it reads, a lacked A and a lacked B, have nothing recorded. The reverse, AB positive to O negative, lacks all three: it reacts, from the A and the B, and sensitises, from the D. O negative to O positive lacks nothing. O positive to O negative lacks only the D antigen, and the verdict is the risk of sensitisation without a reaction. A positive to B positive lacks the A antigen, so it reacts. antigens shows how a group is held: AB positive is A, B and D, all present, and O negative none. A second source that gives the recipient of the fourth transfusion a D antigen is held beside the first. unsettled() names the fact, and the verdict is unchanged, because a conclusion stands if any value on record supports it. This is a statement of a taught rule, and a laboratory decides a real transfusion.

A person is written as a mass in kilograms and a height in metres:

import body

body.person("p1", 70.0, 1.75)
body.person("p2", 50.0, 1.80)
body.person("p3", 95.0, 1.70)
body.person("p4", 80.0, 0.0)
print(body.band("p1"), body.band("p2"), body.band("p3"))
print(body.bmi("p1"), body.bmi("p3"))
print(repr(body.band("p4")), body.bmi("p4"))
n = why_not(body.Person.p4.normal)
print(n)
body.person("p1", 75.0, 1.75)
print(body.band("p1"), body.bmi("p1"))

prints normal underweight obese, 22.857142857142858 32.871972318339104, '' 0.0, rule `body.normal_weight` needs body.Person[p4].bmi >= 18.5, and nothing is recorded there and normal 24.489795918367346. The index is the mass over the square of the height: 70 kilograms at 1.75 metres is 22.9, which is in the normal band from 18.5 up to but not including 25. 50 kilograms at 1.80 is under 18.5, and 95 at 1.70 is 32.9, obese, and also overweight, since the bands above 25 are cumulative; band gives the narrowest. A height of zero draws no index. The index has no answer, so no band is concluded, band gives an empty string and bmi 0.0, and the why_not says that nothing is recorded where the rule reads the index. Writing a person again replaces the values the same source wrote, and the index moves to 24.49. These are bands of a population statistic, not a judgement of a person.

The calculations answer values and write nothing:

import body

print(body.body_mass_index(70.0, 1.75), body.body_mass_index(95.0, 1.7))
print(body.mifflin_st_jeor(70.0, 175.0, 30.0, True), body.mifflin_st_jeor(60.0, 165.0, 30.0, False))
print(body.body_surface_mosteller(70.0, 175.0), body.body_surface_mosteller(60.0, 165.0))
print(body.max_heart_rate_haskell_fox(40.0), body.max_heart_rate_tanaka(40.0))
print(body.max_heart_rate_haskell_fox(20.0), body.max_heart_rate_tanaka(20.0))

prints 22.857142857142858 32.871972318339104, 1648.75 1320.25, 1.8446619684315546 1.6583123951777, 180.0 180.0 and 200.0 194.0. The first line is the index again, as a calculation. The Mifflin-St Jeor equation gives a resting energy of 1648.75 kilocalories a day for a man of 70 kilograms, 175 centimetres and 30 years, and 1320.25 for a woman of 60 kilograms, 165 centimetres and 30 years; they are estimates for adults. Mosteller’s body surface is 1.84 square metres for the first and 1.66 for the second. The two formulas for the maximum heart rate agree at 40 years, at 180 beats a minute, and part at 20, where 220 minus the age gives 200 and 208 minus 0.7 times the age gives 194. Their standard deviations are 12 to 15 beats and about 10, so neither number is a limit for a person.

A group written any other way refuses: body.antigens("C+") says that a blood group is A, B, AB or O, then plus or minus. A height of zero given to body_mass_index refuses with a division by zero.

What body does not do is listed in the Library Reference: plasma and platelet compatibility, the Kell and other blood systems, a rare group, children’s growth and any dose.

Chapter 27 — A twentieth module: sound

sound is not occupational-health advice. It states published limits as data and arithmetic with published formulas. A limit is a figure on a page: whether a worker is protected is decided by a measurement and a hearing conservation programme, and a dose over a limit is not a finding of hearing loss. It gives no diagnosis and no treatment. Ask an occupational-health professional. Nothing in this chapter is a recommendation for anyone’s hearing.

sound reads three kinds of record. From a sound level held for some hours, and the limit it is judged against, it concludes whether the exposure is over its limit. From the segments of a worker’s day, each at its own level, it sums their doses and concludes whether the day is over. From a frequency it concludes whether the tone is audible, or lies below the range of hearing or above it. It also calculates what an acoustician calculates: the level of a pressure, two sources added, the fall with distance, the speed of sound, a wavelength, the Doppler shift and the average level of a dose. Version 1.0.4, at dke.langsyn.com/source/sound/latest/sound.dpy.

A limit is two numbers: a reference level for eight hours, and an exchange rate, the decibels by which a level may rise while the time permitted halves. 90 and 5 is the limit OSHA’s table sets out, and 85 and 3 the level NIOSH recommends. Two exposures to 95 decibels for four hours, against each:

import sound

sound.exposure("e1", 95.0, 4.0, 85.0, 3.0)
sound.exposure("e2", 95.0, 4.0, 90.0, 5.0)
print(sound.over_limit_names(), sound.dose("e1"), sound.dose("e2"))
n = why_not(sound.Exposure.e2.over)
print(n)
sound.exposure("e3", 100.0, 2.0, 90.0, 5.0)
print(sound.dose("e3"), sound.over_limit_names())
sound.exposure("e1", 95.0, 0.5, 85.0, 3.0)
print(sound.over_limit_names(), sound.dose("e1"))
print(sound.permissible_hours(95.0, 90.0, 5.0), sound.permissible_hours(100.0, 90.0, 5.0), sound.permissible_hours(85.0, 85.0, 3.0))

prints ['e1'] 5.039684199579493 1.0, one line for the why_not, 1.0 ['e1'], [] 0.6299605249474366 and 4.0 2.0 8.0. Against 85 and 3 the permitted time at 95 decibels is 0.79 hours, so four hours is a dose of 5.04, five times what the limit permits, and the exposure is over. Against 90 and 5 it is exactly four hours, a dose of 1.0, and the why_not says so: rule `sound.over_limit` needs sound.Exposure[e2].dose > 1.0, and sound.Exposure[e2].dose is 1.0 (real). A dose of one is the whole of what the limit permits, and it is not over it. The third exposure, 100 decibels for two hours against 90 and 5, is also exactly at the limit, so only e1 is over. An exposure over its limit is marked and kept: write e1 again with half an hour and the mark is withdrawn, the dose 0.63. The last line shows the hours a limit permits: 4 at 95 decibels and 2 at 100 against 90 and 5, and 8 at the reference level itself. That formula is this module’s own derivation, not one read from the standards, and a published table rounds where it does not.

A worker’s day is a sum of the doses of its segments, each with its own limit:

import sound

sound.segment("w1", "s1", 95.0, 2.0, 85.0, 3.0)
sound.segment("w1", "s2", 88.0, 3.0, 85.0, 3.0)
sound.segment("w2", "s3", 90.0, 4.0, 90.0, 5.0)
sound.segment("w2", "s4", 95.0, 2.0, 90.0, 5.0)
print(sound.days_over(), sound.total_of("w1"), sound.total_of("w2"))
n = why_not(sound.Worker.w2.over)
print(n)
sound.segment("w1", "s2", 88.0, 1.0, 85.0, 3.0)
print(sound.days_over(), sound.total_of("w1"))
print(sound.twa_from_dose(100.0), sound.twa_from_dose(50.0), sound.twa_from_dose(200.0))

prints ['w1'] 3.2698420997897464 1.0, one line for the why_not, ['w1'] 2.7698420997897464 and 90.0 84.99989177202127 95.00010822797873. The first worker spent two hours at 95 decibels and three at 88 against 85 and 3, doses of 2.52 and 0.75, so the day’s total is 3.27 and it is over. The second spent four hours at 90 and two at 95 against 90 and 5, doses of 0.5 and 0.5, so the total is 1.0, exactly at the limit, and the why_not of over says so. Writing the second segment of the first worker again with one hour replaces its three: the total falls to 2.77, still over. The last line turns a dose in percent into an 8-hour average level: a dose of 100 percent is 90 decibels, and half of it is just under 85, 84.99989177202127 and not 85, because the formula’s constant, 16.61, is itself a rounding of the 5 decibels over the logarithm of 2.

A tone is a frequency in hertz:

import sound

sound.tone("t1", 440.0)
sound.tone("t2", 20.0)
sound.tone("t3", 19.99)
sound.tone("t4", 20000.0)
sound.tone("t5", 40000.0)
print(sound.range_of("t1"), sound.range_of("t2"), sound.range_of("t3"), sound.range_of("t4"), sound.range_of("t5"))
n = why_not(sound.Tone.t3.audible)
print(n)
print(repr(sound.range_of("nobody")))

prints audible audible infrasound audible ultrasound, one line for the why_not and ''. Both ends of 20 to 20,000 are inside the range, so 20 and 20,000 are audible, 19.99 is below it and 40,000 above. The why_not of audible for the 19.99 names the premise that failed: rule `sound.audible_tone` needs sound.Tone[t3].hz >= 20.0, and sound.Tone[t3].hz is 19.99 (real). A name that was never written has no range: the answer is the empty string.

The calculations answer values and write nothing:

import sound

print(sound.level_of_pressure(0.02, 0.00002), sound.level_of_pressure(1.0, 0.00002))
print(sound.add_levels(80.0, 80.0), sound.add_levels(90.0, 70.0))
print(sound.at_distance(90.0, 1.0, 2.0), sound.at_distance(90.0, 1.0, 10.0))
print(sound.speed_of_sound(0.0), sound.speed_of_sound(20.0))
print(sound.wavelength(343.0, 440.0))
print(sound.doppler(440.0, 343.0, 0.0, 30.0), sound.doppler(440.0, 343.0, 0.0, -30.0))
print(sound.level_of_pressure(1.4142135623730951, 1.0))

prints 60.0 93.97940008672037, 83.01029995663981 90.04321373782642, 83.97940008672037 70.0, 331.32 343.2353419474446, 0.7795454545454545, 482.17252396166134 404.6112600536193 and 3.0102999566398125. A pressure of 0.02 pascal against the reference of 20 micropascals is 60 decibels, and one pascal 94. Two equal sources of 80 decibels make 83.0, three decibels more, and a loud source and a quiet one barely add: 90 and 70 make 90.04. In free field the level falls 6.02 decibels for each doubling of the distance, from 90 at one metre to 83.98 at two and to 70 at ten. The speed of sound is 331.32 metres a second at freezing and 343.2 at 20 degrees, and a note of 440 hertz has a wavelength of 0.78 metres. A source at 440 hertz coming toward a still listener at 30 metres a second is heard at 482 hertz, and going away at 405. The last line is the one place a result is not always the same as CPython’s: math.log10 is correctly rounded here, and CPython’s gives 3.010299956639812 for the same pressure.

Some calls refuse where no value exists. sound.level_of_pressure(0.0, 0.00002) refuses with a math domain error, and so does sound.speed_of_sound(-300.0), below absolute zero; sound.doppler with a source moving at the speed of sound refuses with a division by zero, and sound.at_distance with a distance of zero refuses.

A second source can disagree about a fact:

import sound

update(sound.Exposure.e2.hours, 6.0, "a second reading")
print(sound.over_limit_names(), sound.unsettled())

prints ['e2'] ['exposure e2 hours']. The second reading gives e2 six hours and the first four. Both are reasoned about, and the exposure is over if any value puts it over, so the list names it, and unsettled() names the fact the two sources disagree about, whichever of the fields you write it is. A read of one value is a different thing: sound.dose("e2") now refuses, saying that the cell holds two competing values and that the single-claim read refuses.

What sound does not do is listed in the Library Reference: frequency weighting, octave bands, room acoustics and reverberation, sound in water, the action level and the hearing conservation programme of either limit, and any limit of another country.

Chapter 28 — A twenty-first module: finance

finance is not financial advice. It states which of several offers costs the least in total and which carry a payment over a limit you give it, and it does arithmetic with published formulas. It does not say which offer to take: the cheapest in total and an offer over a household’s limit can be the same offer, and the module reports both. Every rate, price and income is yours. Ask a financial adviser. Nothing in this chapter is a recommendation for anyone’s money.

finance reads two kinds of record. From the loan offers you record, each a borrower, a principal, a monthly payment and a number of months, it concludes the total and the interest of each, which offer is the cheapest in total of those that share a borrower and a principal, and which carry a payment over the limit of the borrower’s budget. It also calculates what a lender and a saver calculate: the payment of an amortised loan, compound interest, effective rates, the doubling time, present value and the real rate. Version 1.0.2, at dke.langsyn.com/source/finance/latest/finance.dpy.

An offer is written as a rate, which quote turns into a payment, or as the payment itself. Two offers for the same 200,000, and a household limit:

import finance

finance.quote("a", "ann", 200000.0, 0.05, 360.0)
finance.quote("c", "ann", 200000.0, 0.06, 120.0)
finance.budget("ann", 1500.0)
print(finance.cheapest_offers(), finance.unaffordable_offers())
n = why_not(finance.Offer.a.cheapest)
print(n)
m = why_not(finance.Offer.a.unaffordable)
print(m)
print(finance.total("a"), finance.total("c"))
print(finance.monthly_payment(200000.0, 0.0, 240.0))
finance.quote("b", "ann", 200000.0, 0.055, 240.0)
print(finance.cheapest_offers(), finance.total("b"))
finance.quote("c", "ann", 200000.0, 0.06, 360.0)
print(finance.cheapest_offers(), finance.unaffordable_offers())

prints ['c'] ['c'], two lines for the why_nots, 386511.56856874016 266449.20465995866, 833.3333333333334, ['c'] 330185.9077724346 and ['b'] []. The 30-year loan at 5 percent has a monthly payment of 1073.64 and costs 386,511.57 in all, and the 10-year loan at 6 percent has a payment of 2220.41 and costs 266,449.20. The shorter one is the cheapest in total, and it is also the only one over the household’s limit of 1500, so c is both: the module reports the two facts and does not choose between them. The why_not of cheapest for a says that the absence it needs does not hold, a cheaper offer being present: rule `finance.cheapest_offer` needs finance.Offer[a].cheapest_offer__none1 to be absent, and it is present. The other says that the payment of 1073.64 is not above the limit. A rate of zero is a payment of the principal over the months, 833.33 for 240. A third offer, 20 years at 5.5 percent, costs 330,185.91 and does not displace c. Writing c again as a 30-year loan at 6 percent raises its total to 431,676 and withdraws the mark: b is now the cheapest, and with c’s payment down to 1199.10 nothing is over the limit.

Offers are compared only within a borrower and a principal, and equal totals are each cheapest:

import finance

finance.offer("t1", "bo", 10000.0, 500.0, 24.0)
finance.offer("t2", "bo", 10000.0, 1000.0, 12.0)
finance.offer("t3", "bo", 10000.0, 400.0, 36.0)
finance.offer("t4", "bo", 5000.0, 900.0, 6.0)
print(finance.cheapest_offers(), finance.unaffordable_offers())
print(finance.total("t1"), finance.total("t2"), finance.total("t3"), finance.total("t4"))
n = why_not(finance.Offer.t1.unaffordable)
print(n)
finance.budget("bo", 450.0)
print(finance.unaffordable_offers())
print(finance.total("nobody"))

prints ['b', 't1', 't2', 't4'] [], 12000.0 12000.0 14400.0 5400.0, one line for the why_not, ['t1', 't2', 't4'] and 0.0. The list begins with b from the offers above. t1 and t2 cost the same, 12,000, and each is cheapest, because neither has a cheaper one; t3 costs 14,400 and is not. t4 is for another principal, 5,000, and is alone in its group, so it is cheapest of it. Until a budget is recorded for the borrower nothing is over a limit, and the why_not of unaffordable says that nothing is recorded where the rule reads the limit. With a limit of 450, the payments of 500, 1000 and 900 are over it, and the 400 is not. The total of an offer never written is 0.0.

The calculations answer values and write nothing:

import finance

print(finance.monthly_payment(200000.0, 0.05, 360.0), finance.monthly_payment(10000.0, 0.12, 12.0))
print(finance.compound_amount(1000.0, 0.05, 12.0, 10.0), finance.compound_amount(1000.0, 0.05, 1.0, 10.0))
print(finance.effective_annual_rate(0.1299, 12.0), finance.effective_annual_rate(0.2999, 12.0), finance.effective_annual_rate(0.12, 1.0))
print(finance.continuous_annual_rate(0.05), finance.effective_annual_rate(0.05, 365.0))
print(finance.doubling_exact(8.0), finance.doubling_rule_of_72(8.0), finance.doubling_exact(30.0), finance.doubling_rule_of_72(30.0))
print(finance.present_value(1000.0, 0.10, 5.0), finance.todays_value(1000.0, 0.03, 20.0))
print(finance.real_rate(0.05, 0.03), 0.05 - 0.03)

prints 1073.6432460242781 888.487886783417, 1647.00949769028 1628.894626777442, 0.1379199031259423 0.34475762144741345 0.12, 0.05127109637602404 0.05126749646746255, 9.006468342000588 9.0 2.6419267958111403 2.4, 620.9213230591549 553.6757541863345 and 0.01941747572815533 0.020000000000000004. A year-long loan of 10,000 at 12 percent costs 888.49 a month. 1,000 at 5 percent for ten years is 1647.01 compounded monthly and 1628.89 compounded yearly. A card at 12.99 percent compounded monthly is an effective 13.79 percent and one at 29.99 percent is 34.48, the nominal rate being lower than the one paid. Compounding continuously adds only a little over daily: 5.127 percent against 5.1267. At 8 percent money doubles in 9.006 years and the rule of 72 says 9.0; at 30 percent the exact time is 2.64 years and the rule’s 2.4, which shows why the rule suits rates from 6 to 10 percent. 1,000 in five years at 10 percent is worth 620.92 now, and 1,000 after twenty years of 3 percent inflation buys what 553.68 does today. A nominal 5 percent with 3 percent inflation is a real 1.94 percent by the exact form, where the subtraction gives 2. The 0.020000000000000004 is the double: the last digits of every result here are those of the floating point, and nothing is rounded to the cent.

Some calls refuse where no value exists. finance.monthly_payment(1000.0, 0.05, 0.0) and finance.doubling_exact(0.0) refuse with a division by zero, a sum of no months and one that never doubles, and finance.doubling_exact(-100.0) with a math domain error.

A second source can disagree about a fact:

import finance

update(finance.Offer.t3.payment, 450.0, "a second reading")
print(finance.cheapest_offers(), finance.unaffordable_offers(), finance.unsettled())

prints ['b', 't1', 't2', 't4'] ['t1', 't2', 't4'] ['offer t3 payment']. Both payments are reasoned about, and neither makes t3 the cheapest or puts it over the limit of 450, so the lists do not change, and unsettled() names the payment. The total of t3 is a different matter: finance.total("t3") refuses, saying that the cell holds two competing values.

What finance does not do is listed in the Library Reference: fees folded into an annual percentage rate, taxes, a schedule row by row, variable rates and any statute.

Chapter 29 — A twenty-second module: food

food is not food-safety or medical advice. It states published guidance as data and rules over the facts you record, and arithmetic with published conversions. A temperature in it is a figure from a chart: whether a food is safe to eat is decided by the cook, with a thermometer that reads the food, and whether an ingredient is safe for a person with an allergy is decided by that person and their doctor. A dish it does not mark unsafe is not thereby safe, and a dish it does not mark as holding an allergen holds none only of the ingredients you recorded. It gives no diagnosis. Ask a clinician about an allergy. Nothing in this chapter is a recommendation for anyone’s kitchen or plate.

food reads four kinds of record. From a dish’s kind, the temperature its core reached and the minutes it spent in the danger zone, it concludes whether the dish is undercooked, was held too long, or is unsafe for either cause; a dish whose kind has no minimum recorded it calls unknown, and never safe. From the items you describe and the lines that put them in a dish, it concludes which of the nine major allergens the dish holds. It also calculates what a cook calculates: Fahrenheit to Celsius, a recipe scaled by servings, a baker’s percentage, a volume as a mass, food energy and kilojoules. Version 1.0.3, at dke.langsyn.com/source/food/latest/food.dpy.

The minimum temperatures are a chart’s: FoodSafety.gov’s “Cook to a Safe Minimum Internal Temperature”, whose page reads “Date Last Reviewed November 21, 2024”. They were read from a copy of that page downloaded on 2026-11-09, because an automated fetch of the agency’s sites was refused. chart() writes the eleven standards of its rows that give a temperature, and standard writes one of your own from a Fahrenheit figure. A casserole is one with meat or poultry, or meatless, at 165 F, and the chart’s note that cooked hams packaged in USDA-inspected plants are reheated to 140 F is a kind of its own, ham-packaged, where ham-precooked stays at 165 F:

import food

food.chart()
food.dish("d1", "poultry", 70.0, 30.0)
food.dish("d2", "salmon", 70.0, 30.0)
food.dish("d3", "steak", 63.0, 30.0)
food.dish("d4", "fish", 70.0, 150.0)
food.dish("d5", "casserole", 70.0, 30.0)
food.dish("d6", "ham-packaged", 62.0, 30.0)
food.dish("d7", "ham-precooked", 62.0, 30.0)
food.dish("d8", "casserole", 74.0, 30.0)
print(food.unsafe_dishes(), food.unknown_dishes())
n = why_not(food.Dish.d2.undercooked)
print(n)
m = why_not(food.Dish.d3.undercooked)
print(m)
food.dish("d1", "poultry", 75.0, 30.0)
print(food.unsafe_dishes())
food.standard("salmon", 145.0)
print(food.unsafe_dishes(), food.unknown_dishes())
print(food.danger_zone("usda"), food.danger_zone("uk"))

prints ['d1', 'd4', 'd5', 'd7'] ['d2'], two lines for the why_nots, ['d4', 'd5', 'd7'], ['d4', 'd5', 'd7'] [] and [4.0, 60.0] [8.0, 63.0]. Poultry has a minimum of 165 F, 73.89 C, and the core of d1 reached 70, so it is undercooked and unsafe. d4, a fish with a core of 70, is above its minimum of 62.78 but spent 150 minutes in the danger zone, more than the two hours the rule allows, so it is unsafe for that cause. d5, a casserole at 70, is under the 165 F of a casserole, meatless or not, while d8 at 74 is not. d6 and d7 are cooked hams reheated to 62: that is above the 60 C of a packaged ham, 140 F, and below the 73.89 of other precooked ham, so d7 is unsafe and d6 is not. Nothing is recorded for salmon, so d2 is neither: it is unknown, and the why_not of undercooked says so: rule `food.undercooked_dish` needs food.Standard[salmon].minimum, and nothing is recorded there. A dish whose kind has no standard is never read as safe. The steak with a core of 63 is not undercooked: the chart prints 63 for 145 F, but the module converts exactly, to 62.78, and 63 is above it, which the other why_not shows. Writing d1 again at 75 withdraws its mark. Recording a standard for salmon, as 145 F, makes d2 known, and at 70 degrees it is neither undercooked nor unsafe. The danger zone is two ranges, because agencies define it differently: 4 to 60 for the USDA and 8 to 63 for the United Kingdom.

An ingredient is described by the allergens it holds, and a line puts it in a dish:

import food

food.item("flour", ["wheat"])
food.item("butter", ["milk"])
food.item("eggs", ["egg"])
food.item("pesto", ["milk", "tree_nuts"])
food.item("sugar", [])
food.line("l1", "cake", "flour")
food.line("l2", "cake", "butter")
food.line("l3", "cake", "sugar")
food.line("l4", "pasta", "flour")
food.line("l5", "pasta", "pesto")
food.line("l6", "pasta", "mystery")
print(food.allergens_of("cake"), food.allergens_of("pasta"), food.allergens_of("soup"))
n = why_not(food.Dish.cake.egg)
print(n)
food.line("l2", "cake", "sugar")
print(food.allergens_of("cake"))
food.line("l7", "cake", "eggs")
print(food.allergens_of("cake"))
print(food.unknown_dishes())

prints ['milk', 'wheat'] ['milk', 'tree_nuts', 'wheat'] [], one line for the why_not, ['wheat'], ['egg', 'wheat'] and ['cake', 'pasta']. The cake holds the wheat of its flour and the milk of its butter, and the pasta the milk and the tree nuts of its pesto as well as the flour’s wheat. Sugar was described as holding none of the nine, which is a statement that it is free from them; the mystery line in the pasta names an item that was never described, which holds nothing the rules can see, so the answer for the pasta is only as complete as what you recorded. The module does not say that the pasta is free of anything else. A dish nothing was written for, soup here, holds none and the list is empty. The why_not of egg for the cake says that no line of the cake uses an item that holds an egg. Replacing the butter by sugar withdraws the milk, and adding the eggs brings the egg. Last, the cake and the pasta exist only through their lines. They have no kind, so no standard was there to read, and the module calls them unknown.

The calculations answer values and write nothing:

import food

print(food.fahrenheit_to_celsius(145.0), food.fahrenheit_to_celsius(32.0), food.fahrenheit_to_celsius(212.0))
print(food.scale_by_servings(250.0, 4.0, 6.0), food.scale_by_servings(1.5, 4.0, 10.0))
print(food.bakers_percentage(700.0, 1000.0), food.bakers_percentage(1000.0, 1000.0), food.bakers_percentage(20.0, 1000.0))
print(food.volume_to_mass(1.0, 1.0), food.volume_to_mass(2.0, 0.53))
print(food.food_energy(10.0, 30.0, 5.0, 0.0), food.food_energy(0.0, 0.0, 0.0, 14.0))
print(food.kilocalories_to_kilojoules(100.0), food.kilocalories_to_kilojoules(food.food_energy(10.0, 30.0, 5.0, 0.0)))

prints 62.77777777777778 0.0 100.0, 375.0 3.75, 70.0 100.0 2.0, 236.5882365 250.78353069, 205.0 98.0 and 418.40000000000003 857.72. 145 F is 62.78 C, freezing is 0 and boiling 100. A recipe for 4 that uses 250 grams uses 375 for 6, and 1.5 teaspoons for 4 is 3.75 for 10. A dough with 700 grams of water to 1000 of flour is at 70 percent hydration, the flour itself is always 100, and 20 grams of salt is 2 percent. One US customary cup is 236.59 millilitres, so a cup of a liquid with a density of 1 is 236.59 grams, and two cups of something with a density of 0.53 weigh 250.78; the module holds no density, and the one you supply is the one used. Ten grams of protein, 30 of carbohydrate and 5 of fat are 205 kilocalories at the labelling table’s factors of 4, 4 and 9, 14 grams of alcohol are 98, and a kilocalorie is 4.184 kilojoules, so 100 of them are 418.4 and 205 of them 857.72. Those factors are approximate.

A second source can disagree about a fact:

import food

update(food.Dish.d3.core, 60.0, "a second reading")
print(food.unsafe_dishes(), food.unsettled())

prints ['d3', 'd4'] ['dish d3 core']. The second reading gives the steak a core of 60, below its minimum, and the first gave 63. Both are reasoned about, and a dish is unsafe if any value puts it there, so the steak is now on the list, and unsettled() names the core the two sources disagree about.

Some calls refuse where no value exists: food.item("x", ["gluten"]) refuses an allergen that is not one of the nine, food.danger_zone("who") an agency other than usda and uk, and food.bakers_percentage(5.0, 0.0) a flour of zero, with a division by zero.

What food does not do is listed in the Library Reference: a cooling curve, a rest, nutrients beyond protein, carbohydrate, fat and alcohol, cooking losses and yields, and the rows of the chart that give no temperature.

Chapter 30 — A twenty-third module: architecture

architecture is not engineering, naval-architecture, acoustic-design or heritage advice. It states published definitions and simple models as rules over the facts you record. A tower in it is a straight column of even weight, a hull is a box, and a room is Sabine’s equation: whether a real structure is safe, a real ship stable or a real hall good for music is decided by those who survey, test and certify it. Nothing in this chapter is a judgement of any real building, ship or hall.

architecture reads eight kinds of record. From the features you record for a building, it concludes whether the building is Gothic or Romanesque and whether it carries a dome on pendentives. From the heights and years of structures, it concludes which was the tallest structure, and which the tallest building, when it was completed. It decides whether a tower tips on the model of a straight column, whether a box hull’s metacentric height is not positive, whether a room rings longer or shorter than you want, and whether a channel falls too little. It also calculates a lean, Sabine’s reverberation time, a hull’s metacentric height, a gradient and a pendulum. Version 1.0.2, at dke.langsyn.com/source/architecture/latest/architecture.dpy.

A style is a family resemblance, and a rule makes it strict. building() writes all nine features it knows, true for those you name and false for the rest:

import architecture

architecture.building("cologne", ["pointed_arch", "rib_vault", "flying_buttress"])
architecture.building("abbey", ["round_arch", "thick_walls", "groin_vault"])
architecture.building("hagia", ["dome", "pendentives"])
architecture.building("chapel", ["pointed_arch", "rib_vault", "round_arch"])
print(architecture.styles_of("cologne"), architecture.styles_of("abbey"), architecture.styles_of("hagia"))
print(architecture.styles_of("chapel"), architecture.styles_of("nowhere"))
n = why_not(architecture.Building.chapel.gothic)
print(n)
architecture.building("chapel", ["pointed_arch", "rib_vault", "flying_buttress", "round_arch"])
print(architecture.styles_of("chapel"))

prints ['gothic'] ['romanesque'] ['pendentive_dome'], [] [], one line for the why_not and ['gothic']. Gothic is pointed arches, rib vaults and flying buttresses, the three of the four features its definition names that are parts of the structure; the fourth is its windows. Romanesque is round arches and thick walls with a barrel vault or a groin vault. A dome on pendentives is a form and not a style, because a pendentive is a device for setting a round dome on a square room, not a period. The chapel has two of the three Gothic features, and the why_not names the third: rule `architecture.gothic_building` needs architecture.Building[chapel].flying_buttress == True, and architecture.Building[chapel].flying_buttress is False (bool). nowhere was never described, so it has no style, which is not the same as having none. Writing the chapel again with flying buttresses makes it Gothic.

A record of height is kept for structures and for buildings separately. Here are six of the records on the list of the tallest structures built before the twentieth century, with the years the two that fell came down:

import architecture

architecture.structure("lincoln", 160.0, 1311.0, True)
architecture.fell("lincoln", 1548.0)
architecture.structure("beauvais", 153.0, 1569.0, True)
architecture.fell("beauvais", 1573.0)
architecture.structure("cologne", 157.0, 1880.0, True)
architecture.structure("washington", 169.0, 1884.0, False)
architecture.structure("eiffel", 300.0, 1889.0, False)
architecture.structure("ulm", 161.53, 1890.0, True)
architecture.structure("pyramid", 146.6, -2570.0, False)
print(architecture.tallest_structures())
print(architecture.tallest_buildings())
print(architecture.record_until("cologne", False), architecture.record_until("cologne", True), architecture.record_until("eiffel", False))
print(architecture.record_until("pyramid", False))
n = why_not(architecture.Structure.ulm.tallest_structure)
print(n)
update(architecture.Structure.oldspire.height, 170.0, "structure")
print(architecture.undated_structures(), architecture.tallest_structures())

prints ['beauvais', 'cologne', 'eiffel', 'lincoln', 'pyramid', 'washington'], ['beauvais', 'cologne', 'lincoln', 'ulm'], 1884.0 1890.0 10000.0, 1311.0, one line for the why_not, and ['oldspire'] ['beauvais', 'cologne', 'eiffel', 'lincoln', 'pyramid', 'washington']. A structure is the tallest when it was completed if no structure you recorded was completed in that year or before it, still stood after it and was taller. Lincoln’s spire fell in 1548, so Beauvais, at 153 metres, is the tallest in 1569 though it is shorter than Lincoln was. The Washington Monument and the Eiffel Tower are not buildings, so they end Cologne’s record as a structure, in 1884, and leave its record as a building to Ulm, in 1890. record_until gives those years, and 10000.0 for the Eiffel Tower, which nothing you recorded passed: passed by nothing recorded, not held for ever. The Great Pyramid, completed about 2570 BC, is written with a negative year and compared like any other. Its record passes in 1311 here, with Lincoln, because Lincoln is the first taller structure this program records; the list ends it in 1221, with Old St Paul’s, which is not recorded here. Ulm was not the tallest structure, and its why_not says rule `architecture.tallest_structure_when_completed` needs architecture.Structure[ulm].tallest_structure_when_completed__none1 to be absent, and it is present: the rule asks that no taller structure be found, and one was. Last, a structure with a height and no year is undated and is not ranked. With no year, nothing could be completed before it, so it would otherwise be called the tallest.

A tower tips, on the model, when its centre of mass, half the top’s offset from the vertical, passes the edge of its base. A hull is unstable when its metacentric height is not positive:

import architecture

architecture.tower("t1", 56.67, 3.9, 15.484)
architecture.tower("t2", 40.0, 6.0, 6.0)
architecture.tower("t3", 40.0, 6.0, 5.0)
print(architecture.tipping_towers())
s = current(architecture.Tower.t1.shift)
r = current(architecture.Tower.t1.radius)
print(s.value, r.value)
architecture.hull("box1", 6.0, 3.0, 4.0)
architecture.hull("box2", 6.0, 3.0, 2.5)
architecture.hull("box3", 10.0, 2.0, 3.0)
print(architecture.unstable_hulls())
architecture.hull("box1", 6.0, 3.0, 2.0)
g = current(architecture.Hull.box1.gm)
print(architecture.unstable_hulls(), g.value)

prints ['t3'], 1.95 7.742, ['box1', 'box2'] and ['box2'] 0.5. A tower 56.67 metres high with its top 3.9 metres out and a base 15.484 metres across shifts its centre of mass 1.95 metres, far inside a radius of 7.742, and does not tip on the model; whether a real tower stands depends also on the ground under it, which the model does not see. t2’s shift is exactly its radius, which is not past it, and t3’s passes it. A box hull’s metacentric height is half its draught, plus its beam squared over twelve times its draught, less the height of its centre of gravity: 1.5 + 1.0 - 4.0 for box1, which is unstable, and exactly 0 for box2, which is unstable too. Ballast that lowers box1’s centre of gravity to 2 metres gives it 0.5 and withdraws the verdict. The metacentric height measures stability at small angles of heel only.

A room’s time is Sabine’s, from its volume and the absorption of the surfaces that line it:

import architecture

architecture.room("hall", 18750.0, 1.6, 2.2)
architecture.room("booth", 30.0, 0.3, 0.5)
print(architecture.unmeasured_rooms())
architecture.surface("s1", "hall", 2000.0, 0.3)
architecture.surface("s2", "hall", 1500.0, 0.1)
architecture.surface("s3", "booth", 40.0, 0.9)
t = current(architecture.Room.hall.rt)
print(architecture.live_rooms(), architecture.dead_rooms(), t.value)
architecture.surface("s4", "hall", 800.0, 0.8)
u = current(architecture.Room.hall.rt)
print(architecture.live_rooms(), u.value)

prints ['booth', 'hall'], ['hall'] ['booth'] 4.025 and [] 2.1717625899280577. Before any surface is recorded, both rooms are unmeasured: they have no time, and are neither too live nor too dead. Then the hall’s two surfaces absorb 600 and 150 square metres, and 0.161 x 18750 / 750 is 4.025 seconds, longer than the 2.2 you want, while the booth’s 36 give it 0.13, shorter than its 0.3. A fourth surface brings the hall’s absorption to 1390 and its time to 2.17, inside the range, and the verdict is withdrawn. The absorption is the exact sum of the surfaces’ sabins, rounded once, so the order you write them in does not move it.

A channel is held to a standard, and a standard can be read two ways:

import architecture

architecture.vitruvius()
architecture.channel("nimes", 50000.0, 17.0, "vitruvius")
architecture.channel("steep", 1000.0, 10.0, "vitruvius")
architecture.channel("mill", 1000.0, 1.0, "mine")
print(architecture.flat_channels(), architecture.channels_without_standard())
print(architecture.unsettled())
architecture.fall("mine", 1.0 / 2000.0, "my survey")
print(architecture.flat_channels(), architecture.channels_without_standard())

prints ['nimes'] ['mill'], ['fall vitruvius minimum'] and ['nimes'] []. Vitruvius’s minimum fall for a channel is half a foot in a hundred in one translation and one in 4,800 in another account, and vitruvius() writes both as two values of one fact. A channel falling 17 in 50,000 is too flat under the first and within the second, and it is marked too flat because one value puts it there; a channel not marked is within every value. unsettled() names the fact the two disagree about. The mill’s standard, mine, has no value until you write one, so it is marked as without a standard rather than within it; one in 2,000 then holds it, and its one in 1,000 is within it.

The calculations answer values and write nothing:

import architecture

print(architecture.lean_degrees(3.9, 56.67), architecture.top_offset(56.67, 3.97))
print(architecture.reverberation_time(18750.0, 1390.0), architecture.metacentric_height(10.0, 2.0, 3.0))
print(architecture.gradient(456.0, 0.025), architecture.one_in(456.0, 0.025))
print(architecture.pendulum_period(1.0), architecture.pendulum_length(2.0))
print(architecture.lean_degrees(1.0, 14.0))

prints 3.936858505470097 3.9329360885791114, 2.1717625899280577 2.166666666666667, 5.482456140350877e-05 18240.0, 2.0064092925890407 0.9936213855661318 and 4.085616779974876. A top 3.9 metres out on a height of 56.67 is a lean of 3.94 degrees, and a lean of 3.97 degrees on that height puts the top 3.93 metres out. The hall above rings for 2.17 seconds, and a box hull 10 wide, 2 deep, with its centre of gravity 3 above the keel has a metacentric height of 2.17 metres. A drop of 2.5 centimetres in 456 metres is one in 18,240. A pendulum a metre long swings back and forth in 2.006 seconds at standard gravity, and one that takes two seconds is 0.994 metres long. Each step is rounded once, and atan and tan are correctly rounded, so a lean or an offset can differ from CPython’s in the last place: the last line is 4.085616779974877 in CPython 3.11. Every other result here is CPython’s to the last digit.

A second source can disagree about a fact:

import architecture

update(architecture.Hull.box3.kg, 6.0, "a second survey")
print(architecture.unstable_hulls(), architecture.unsettled())

prints ['box2', 'box3'] ['fall vitruvius minimum', 'hull box3 kg']. The second survey puts box3’s centre of gravity at 6 metres, where its metacentric height is negative, and the first put it at 3. Both are reasoned about, and a hull is unstable if any value makes it so, so box3 is on the list, and unsettled() names the fact beside the one Vitruvius left.

Some calls refuse where no value exists: architecture.building("x", ["spire"]) refuses a feature that is not one of the nine, architecture.record_until("ulm", False) a structure that was not the tallest when it was completed, and architecture.reverberation_time(100.0, 0.0) an absorption of zero, with a division by zero. record_until also refuses while a structure’s height or year holds two values, since either could move the year it gives.

What architecture does not do is listed in the Library Reference: loads, materials and foundations, wind, stability at large angles of heel, hull forms other than a box, reverberation formulas other than Sabine’s, and the flow of water in a channel.

Chapter 31 — A twenty-fourth module: elections

elections is not legal or electoral advice. It states published counting methods as rules and calculations over the ballots and populations you record. It certifies no election and counts no real ballot for anyone: which method an election uses, who may vote and what a ballot says are decided by the law and the officials who run it. Nothing in this chapter is a judgement of any real election.

elections reads five kinds of record. From an election’s ballots, each a ranking of every candidate and the number of voters who cast it, it concludes which candidate the most voters put first, which more than half put first, which beats every other candidate head to head and which every other beats, and which has the highest Borda score. It finds that no candidate beats every other when the head-to-head counts run in a cycle, and it refuses to name a winner while a count is disputed. It also counts an instant runoff, round by round, and calculates the Hare and Droop quotas and a house’s seats by four methods. Version 1.0.0, at dke.langsyn.com/source/elections/latest/elections.dpy.

You write an election with its candidates, and a ballot for each ranking, with the number of voters who cast it. The four cities of the example the sources share choose a capital: 42 voters live in Memphis, 26 in Nashville, 15 in Chattanooga and 17 in Knoxville, and each wants the nearest city first:

import elections

elections.election("tn", ["memphis", "nashville", "chattanooga", "knoxville"])
elections.ballot("m", "tn", 42.0, ["memphis", "nashville", "chattanooga", "knoxville"])
elections.ballot("n", "tn", 26.0, ["nashville", "chattanooga", "knoxville", "memphis"])
elections.ballot("c", "tn", 15.0, ["chattanooga", "knoxville", "nashville", "memphis"])
elections.ballot("k", "tn", 17.0, ["knoxville", "chattanooga", "nashville", "memphis"])
w = elections.winners("tn")
print(w["plurality"], w["majority"], w["condorcet"], w["condorcet_loser"], w["borda"])
print(elections.first_choices("tn"))
print(elections.borda_scores("tn"))
print(elections.head_to_head("tn")["nashville>memphis"], elections.without_condorcet_winner())
n = why_not(elections.Candidate.memphis.condorcet)
print(n)
print(elections.runoff("tn"))

prints ['memphis'] [] ['nashville'] ['memphis'] ['nashville'], then {'chattanooga': 15.0, 'knoxville': 17.0, 'memphis': 42.0, 'nashville': 26.0}, {'chattanooga': 173.0, 'knoxville': 107.0, 'memphis': 126.0, 'nashville': 194.0}, 58.0 [], one line for the why_not and the instant-runoff count, which is taken up below. Each method gives its own answer. Memphis has the most first choices, 42, and is the plurality winner, but 42 is not more than half of 100, so there is no majority winner. Nashville beats every other city head to head, and 58 voters rank it over Memphis, so it is the Condorcet winner, and it has the highest Borda score, 194. Memphis, which the most voters put first, is beaten by every other city, so it is also the Condorcet loser. The Borda score is the sum of a candidate’s head-to-head tallies, which is the count of n - 1 points for a first place down to none for a last. The election has a Condorcet winner, so without_condorcet_winner() is empty. The why_not on Memphis’s condorcet says rule `elections.condorcet_winner` needs elections.Candidate[memphis].condorcet_winner__none1 to be absent, and it is present: the rule asks that no duel be lost or tied, and one was.

The last line is an instant-runoff count, which takes the ballots round by round. Each ballot counts for the highest candidate on it still standing, and the candidate with the fewest votes goes out. It is a list of the four rounds’ tallies, [{'chattanooga': 15.0, 'knoxville': 17.0, 'memphis': 42.0, 'nashville': 26.0}, {'knoxville': 32.0, 'memphis': 42.0, 'nashville': 26.0}, {'knoxville': 58.0, 'memphis': 42.0}, {'knoxville': 100.0}]. Chattanooga, with 15, goes out first, and its 15 voters go to Knoxville, their next choice. Nashville goes out next, and its 26 go to Knoxville too, who then holds 58 against Memphis’s 42 and wins. The instant runoff chooses Knoxville, whom none of the three methods above chose: a method is a rule for reading the same ballots, and they do not agree. A tie for last place refuses, since the method names no way to break one.

A Condorcet winner need not exist:

import elections

elections.election("cycle", ["a", "b", "c"])
elections.ballot("x", "cycle", 1.0, ["a", "b", "c"])
elections.ballot("y", "cycle", 1.0, ["b", "c", "a"])
elections.ballot("z", "cycle", 1.0, ["c", "a", "b"])
w = elections.winners("cycle")
print(w["condorcet"], w["condorcet_loser"], w["plurality"], w["borda"])
print(elections.without_condorcet_winner())

prints [] [] ['a', 'b', 'c'] ['a', 'b', 'c'] and ['cycle']. Two of the three voters rank A over B, two rank B over C and two rank C over A, so every candidate beats one and loses to another, and no candidate beats every other: the election is in without_condorcet_winner(). Nor does any candidate lose to every other. Each candidate has one first choice, the most any has, so each is a plurality winner, and each has the same Borda score.

A rule that adds up a fact adds every value it holds, so a second count of a ballot would be added to the first. recount() writes the second count under a source of its own, and the election is then in dispute and decides nothing:

import elections

elections.election("pair", ["x", "y"])
elections.ballot("b1", "pair", 5.0, ["x", "y"])
elections.ballot("b2", "pair", 3.0, ["y", "x"])
print(elections.winners("pair")["plurality"])
elections.recount("b2", 4.0, "recount")
print(elections.disputed_elections())
d = why_not(elections.Candidate.x.plurality)
print(d)
forget(elections.Ballot.b2.count, 4.0, "recount")
print(elections.disputed_elections(), elections.winners("pair")["plurality"])

prints ['x'], ['pair'], one line for the why_not and [] ['x']. Five voters put x first and three put y first, so x wins. While the ballot b2 holds both 3 and 4, no winner is concluded for pair and the readers refuse it, and the why_not on x’s plurality says rule `elections.plurality_winner` needs elections.Election[pair].disputed to be absent, and it is present. A winner concluded from the total, 7 for y against 5, would be the winner under neither count: x wins under each. Forgetting the claim that is not to stand settles the dispute, and every verdict returns.

The calculations answer values and write nothing. A house’s seats are shared among states by their populations, in whole numbers, so a tie is found exactly:

import elections

print(elections.hare_quota(100000, 8), elections.droop_quota(100000, 8))
print(elections.largest_remainder({"a": 6, "b": 6, "c": 2}, 10))
print(elections.highest_averages({"a": 100000, "b": 80000, "c": 30000, "d": 20000}, 8, "dhondt"))
print(elections.highest_averages({"a": 100000, "b": 80000, "c": 30000, "d": 20000}, 8, "sainte_lague"))
print(elections.highest_averages({"a": 100000, "b": 80000, "c": 30000, "d": 20000}, 8, "huntington_hill"))
print(elections.priority_value(100000, 3))

prints 12500.0 11111.111111111111, {'a': 4, 'b': 4, 'c': 2}, {'a': 4, 'b': 3, 'c': 1, 'd': 0}, {'a': 3, 'b': 3, 'c': 1, 'd': 1}, {'a': 3, 'b': 3, 'c': 1, 'd': 1} and 28867.51345948129. A hundred thousand votes for eight seats is a Hare quota of 12,500 and an exact Droop quota of 11,111.11. Largest remainders gives each state the whole part of its share, and the one seat left goes to the largest remainder, which is state c’s. D’Hondt divides each population by one, two, three and on, which favours the large state: d gets none. Sainte-Lague divides by one, three, five and on, and gives d a seat. Huntington-Hill gives every state one seat first and then ranks each population over the square root of n (n + 1), which is a priority of 28,867.51 for a state of 100,000 holding three seats; it compares squared populations in whole numbers.

Some calls refuse where no answer exists:

import elections

elections.election("duo", ["u", "v"])
elections.ballot("d1", "duo", 5.0, ["u", "v"])
try:
    elections.ballot("d2", "duo", 3.0, ["v"])
except refuse as r:
    print(r.reason)
try:
    elections.highest_averages({"a": 100000, "b": 100000}, 3, "dhondt")
except refuse as r:
    print(r.reason)
try:
    elections.winners("nowhere")
except refuse as r:
    print(r.reason)
elections.recount("d1", 7.0, "recount")
try:
    elections.winners("duo")
except refuse as r:
    print(r.reason)
forget(elections.Ballot.d1.count, 7.0, "recount")
print(elections.winners("duo")["plurality"])

prints a ballot ranks every candidate of its election once, a tie for the last seat, election nowhere is not written, the count of election duo is disputed, and disputed_elections() names it and ['u']. A ballot that leaves candidates out refuses, and so does a tie that more states share than there are seats left to give: two states of 100,000 tie for the last seat of three, and no method here breaks a tie. An election that is not written refuses its readers, and so does a disputed one, and settling the dispute returns its winners.

What elections does not do is listed in the Library Reference: the law of any election, who may vote, what a ballot says, ballots that tie or leave candidates out, and the allocation of seats from ballots rather than from populations.

Chapter 32 — What it costs

Nothing beyond what you already pay. Reasoning modules carry no separate charge: importing one, and everything it then does, is billed by the same per-operation meter as any other work you send. A module that reasons more costs more, in proportion, and one you never call costs nothing.


Chapter 33 — Where to go next

  • The Library Reference lists every kind, field, derived field and query, module by module, and every public name in dkepy.
  • dkekey issues a store key on your own machine, for a program with no browser; MCP Reference §3.1 says how, and dke.keys in dkepy does the same from Python.
  • The Python Reference §9 specifies module semantics in general — resolution, namespacing, reconciliation.
  • The Python Tutorial teaches the language modules are written in.
  • The Library Specification states the modules and the dkepy package as a contract: what you may depend on, and what may change under you.

Revision history

Newest first. The revision is the surface’s — one number stated by every published document, the web pages and the service wire, moving whenever any document changes. This list records the revisions at which this document changed and what changed in it; a revision absent here left this document as it was. The Version line at the top states this document’s version, the revision it last changed in and the current revision; from rev 148 each entry opens with the version that revision gave this document. The history starts at rev 1. What a revision promises is stated in the Python Specification, its section 11.1.

  • 1.0.5 · rev 171 (2026-11-15) — Chapter 12 names quantum 1.3.1, where it named 1.3.0. The Version line states API 1.5: a module may state its version on a top-level __version__ line, which a compile given no version records and info_module reports (Python Reference §9.1), and an addition moves the API’s MINOR.
  • 1.0.4 · rev 163 (2026-11-14) — The Version line states API 1.4: range is a value of its own, walked without being built, so a for over a million numbers runs, and a range keeps its three bounds as .start, .stop and .step (Specification §8.6), and an addition moves the API’s MINOR.
  • 1.0.3 · rev 151 (2026-11-13) — The Version line states API 1.3: a rule premise may test part of a text, as r.path.startswith("/srv/"), t.name.endswith(".test") and "admin" in a.role (Specification §14.2), and an addition moves the API’s MINOR.
  • 1.0.2 · rev 150 (2026-11-13) — The Version line states API 1.2: calls made with keys that may only read run on one store together, where every call on a store ran alone (MCP §9.8), and an addition moves the API’s MINOR. Nothing else changed, and no module did.
  • 1.0.1 · rev 149 (2026-11-13) — The Version line states API 1.1: DKE Python gained a form a rule’s conclusion may take, a text written from values the rule read (Python Specification §14.3), and an addition moves the API’s MINOR. Nothing else changed, and no module did.
  • 1.0.0 · rev 148 (2026-11-12) — This document states a version of its own, 1.0.0, first on the Version line and followed by the revision it last changed in, and from this revision each entry here opens with the version it made (Python Specification §11.1). Nothing else changed.
  • rev 147 (2026-11-12) — The Version line states API 1.0: the API version is MAJOR.MINOR. Nothing else changed.
  • rev 145 (2026-11-12) — The library carries physics at 1.0.3, chemistry at 1.0.3, biology at 1.0.2, astronomy at 1.0.2, geography at 1.0.2 and mathematics at 1.0.2, whose calculations answer wherever their value is a real, where they answered inf, nan, 0.0 or a wrong solution when their work passed the range of a real. The outputs of Chapters 15 to 20 are unchanged.
  • rev 144 (2026-11-12) — The library carries body at 1.0.2, food at 1.0.3, colour at 1.0.1, sound at 1.0.4, finance at 1.0.2, earthscience at 1.0.2 and architecture at 1.0.2, whose calculations answer wherever their value is a real, where they answered inf, nan or 0.0 when their work passed the range of a real. Chapter 25’s magnification prints the exact -0.5, and Chapter 28’s payments, totals and rates print digits closer to the exact values; 0.12 compounded once is 0.12. The other chapters’ outputs are unchanged.
  • rev 143 (2026-11-12) — The library carries statistics at 1.0.3, whose probabilities and descriptive statistics answer wherever their value is a real, where they answered inf, nan or 0.0 when their work passed the range of a real. Chapter 21’s outputs are unchanged.
  • rev 142 (2026-11-12) — The library carries sound at 1.0.3, whose add_levels adds two levels of any size; the example’s second sum prints its last digit as the exact value rounds it.
  • rev 133 (2026-11-10) — The library carries eleven modules at new versions, which no longer add a fact two sources give into a sum: while a member is bound to a sum twice, the sum is disputed and has no value, a query names it, and its reader refuses it until the claim that is not to stand is forgotten. Chapter 15 shows a series group refused and named by physics.disputed_groups(), Chapter 16 a sulfane whose weight is disputed while its formula stands and a reaction that faults() lists, and Chapter 21 a study whose disputed count is not pooled. No other chapter changed except its statement of the version, and Chapter 21’s had said 1.0.0 since 1.0.1 was published.
  • rev 132 (2026-11-10) — The library carries quantum at 1.3.0, whose exact check of a reversible circuit takes a measurement: a circuit may measure a qubit in the X basis and correct its sign with a gate that runs only where the outcome was 1, and the check holds over every outcome. No chapter changed except its statement of the version.
  • rev 131 (2026-11-10) — The library carries quantum at 1.2.0, which adds the Toffoli, CCZ and Fredkin gates and the exact checks rev_same and rev_meets of a reversible circuit. No chapter changed except its statement of the version.
  • rev 130 (2026-11-10) — Chapter 31 teaches elections 1.0.0, which is not legal or electoral advice: an election’s ballots and the winners under plurality, majority, the Condorcet method and the Borda count, an instant-runoff count, an election with no Condorcet winner, a count in dispute that decides nothing, and the Hare and Droop quotas and four methods of sharing seats. What it costs is now Chapter 32 and Where to go next Chapter 33.
  • rev 129 (2026-11-09) — Chapter 30 teaches architecture 1.0.0: a building’s style and the feature it lacks, the records of height of six structures, a tower and a hull on their models, a room’s reverberation time over its surfaces, a channel held to Vitruvius’s two readings, the calculations, and a second source that disagrees. What it costs is now Chapter 31 and Where to go next Chapter 32.
  • rev 126 (2026-11-09) — Chapter 29 food is at 1.0.2, credits FoodSafety.gov’s chart, and its first program adds a meatless casserole and two hams reheated to 62 C, under ham-packaged and ham-precooked, which chart() now writes among eleven standards.
  • rev 125 (2026-11-09) — Chapters 18, 22 and 26 name astronomy, earthscience and body at 1.0.1. The astronomy chapter says that window() refuses with up() while a booking’s start or end has two values.
  • rev 124 (2026-11-09) — Chapters 27 to 29 name sound 1.0.1, finance 1.0.1 and food 1.0.1, whose unsettled() names a dispute over any fact you record, and each shows it beside the second reading it already gave.
  • rev 123 (2026-11-08) — The library carries calendars, colour and body at 1.0.0, each on the source surface. Chapter 24 is new: calendars; Chapter 25 colour; Chapter 26 body, which says in its opening that it is not medical advice. The library also carries sound, finance and food at 1.0.0: Chapter 27 is sound, which says in its opening that it is not occupational-health advice; Chapter 28 finance, not financial advice; Chapter 29 food, not food-safety or medical advice. What it costs is now Chapter 30 and where to go next Chapter 31.
  • rev 122 (2026-11-08) — The library carries statistics, earthscience and computing at 1.0.0, each on the source surface. Chapter 21 is new: statistics; Chapter 22 earthscience; Chapter 23 computing. What it costs is now Chapter 24 and where to go next Chapter 25.
  • rev 121 (2026-11-08) — The library carries astronomy, geography and mathematics at 1.0.0, each on the source surface. Chapter 18 is new: astronomy; Chapter 19 geography; Chapter 20 mathematics. What it costs is now Chapter 21 and where to go next Chapter 22.
  • rev 119 (2026-11-08) — Chapters 15 and 16 name physics 1.0.1 and chemistry 1.0.1, whose unsettled() names a dispute over any fact you record.
  • rev 118 (2026-11-08) — Chapters 15 to 17 are new: physics 1.0.0, checking a hand-over of units, the dimensions of equations and a circuit, and a few calculations; chemistry 1.0.0, reading a molecule’s formula, weight and groups from its bonds, a reaction’s enthalpy, a balanced equation and a pH; and biology 1.0.0, a network’s scope and what it needs, a pedigree’s carriers and what its mode cannot explain, and a few calculations. What it costs is now Chapter 18 and where to go next Chapter 19.
  • rev 116 (2026-11-08) — Chapter 14 is new: harmony 1.0.0, marking an exercise and its correction, a melody’s rhythm and a few calculations. What it costs is now Chapter 15 and where to go next Chapter 16.
  • rev 114 (2026-11-06) — Chapter 12 names quantum 1.1.0, and shows a measurement whose value comes from a 256-bit key, ChaCha20 as RFC 8439 defines it, the generator a secret can stand behind.
  • rev 112 (2026-11-06) — Chapter 12 names quantum 1.0.3, where it still named 1.0.1: 1.0.2 collapses a measured state in one vector.apply, and 1.0.3 weighs its two outcomes in one vector.sumsq.
  • rev 33 (2026-10-23) — “What you run is what you can read” states the guarantee on its own.
  • rev 31 (2026-10-19) — This is the DKE Library’s Tutorial, served at /tutorial/dke/library/ beside the Library Specification; the old address leads here. The introduction says what the library holds: the reasoning modules, dkepy and dkekey. Cross-references name the Library Reference, and Chapter 15 points to dkekey and to the Reference’s names for dkepy. The Status line says what the document is.
  • rev 30 (2026-10-17) — Chapter 7 names code 1.12.3, a release that changes one comment and nothing the module answers.
  • rev 27 (2026-10-17) — Each module moves to its next patch release, whose source opens with an SPDX licence line and is otherwise unchanged: Chapter 7 names code 1.12.2, and each module’s chapter states its own new version.
  • rev 26 (2026-10-17) — The header states Version, Status, Audience and the Markdown copy, one field to a line, and the introduction follows it. Chapter 14: a module carries no separate charge. Chapter 15 links the Library Specification.
  • rev 25 (2026-10-16) — Chapter 12’s measurement example is a whole program: it builds the Bell pair itself, where it continued the example before it.
  • rev 24 (2026-10-16) — Two chapters: Chapter 12 on quantum and Chapter 13 on pqc, each with examples whose output is shown as a store printed it. What it costs is now Chapter 14 and where to go next Chapter 15. Chapter 2 names the three modules that have standing constraints, where it named agenda alone.
  • rev 22 (2026-10-14) — The modules and dkepy are served from https://dke.langsyn.com/source/, beside these documents, and every download link names that address. The dke.langsyn.org addresses still lead to the same files.
  • rev 4 (2026-10-11) — The closing chapter is numbered, Chapter 13, as the other tutorials number theirs. Cross-references take the published documents’ short names, the ones the footer uses — Modules Reference, Python Reference, Python Tutorial, MCP Reference, Python Specification. Nothing else changed.
  • rev 3 (2026-10-11) — The revision is the surface’s: one number shared by every published document, the web pages and the service wire. This section says so, and the Version line moves from rev 1 to it. Nothing else changed.
  • rev 1 (2026-10-11) — The Version line states the document’s revision and this section is added. Nothing else changed.