The build¶
cartolex.build runs the stages of a project (Derived results, caches, outputs and logs): it
knows what each stage reads, decides from the records alone whether a result
is up to date, runs what needs it in staging folders that a killed process
cannot damage, and says beforehand what it will do. The engine packages
(cartolex.lexicon, cartolex.atlas) never import it.
from cartolex.build import build, plan, status
from cartolex.project import Project
with Project.open(folder, write=True) as project: # repairs an interrupted swap
for s in status(project).values(): # the six states, with reasons
print(s.describe())
print(plan(project).describe()) # the dry run
result = build(project, consent=ask, progress=show, cancel=stop)
print(result.summary())
module |
what it holds |
|---|---|
|
|
|
|
|
file, table and code fingerprints |
|
run ids, |
|
|
|
|
|
|
|
available memory, peak memory, the boot id |
|
the ownership table: where each engine file lives in a project; |
|
the runners of cartolex’s stages, the settings they give the engine, |
The swap of a staging folder into place and its journal are part of the format
and live in cartolex.project.generations, so that opening a project for
writing can repair them without the build.
Declaring a stage¶
A stage is a frozen Stage in a Registry. The registry follows the order of
STAGE_IDS and checks that the stages fit together: every upstream stage comes
earlier, parameter names are unique and are not global, and every size a rule
needs is reported by an upstream stage.
Stage(
"themes.group",
"group keywords into topics and themes", # the plain name people see
upstream=("themes.space",),
decisions=(), # decision files it reads
sources=(), # source tables it reads
project=(), # parts of project.json it reads
params=(ParamSpec("top_groups", "int", "about this many groups at the top level",
default=15, minimum=2, maximum=500), ...),
checks=(CrossCheck(...),), # refusals that need the project's sizes
uses=(), # global parameters: seed, year
provides=(), # sizes its counts report
cost=CostModel("kept_keywords", 1.0, 1e-3, 100.0, 8e-6, memory_exponent=2.0),
run=runner, # run(ctx) -> counts
prepare=None, # prepare(project) before it runs
version=1, # raised when what it produces changes
)
field |
meaning |
|---|---|
|
stages whose results it reads; a skipped upstream stage is simply not read |
|
the files whose fingerprints go into |
|
parts of |
|
the stage runs only when its |
|
another reason to skip it (no overlay) |
|
the build asks consent first, and says what goes out and what it costs |
|
a killed run resumes from its last chunk |
|
the sizes its counts report: |
|
how its time and peak memory are estimated |
|
called by a build, not a dry run, just before the stage’s inputs are fingerprinted: it may write a decision the stage needs (the first map version, the rebased theme tree); what it did goes into the run’s warnings |
|
the stage’s behaviour version (see below) |
STAGES declares cartolex’s ten stages, each with the runner that calls the
engine (below). Registry.with_runners({...}) returns a registry with other
runners (a test’s fakes, the AI clean-up with its key), and
Registry.replace(stage_id, **changes) changes any other field.
Parameters¶
decisions/params.json holds only what people set, per stage:
{"stages": {"themes.group": {"top_groups": 12}}}. Every other value is the
parameter’s default or its rule. seed and pinned_year sit at the top of the
file and reach the stages that declare them in uses as seed and year (the
year every date window counts back from: the pinned year, or the current one).
Each stage’s run.json records every effective value and its origin:
"parameters": {
"depth": {"value": 2, "from": "rule", "rule": "theme_depth"},
"top_groups": {"value": 12, "from": "params.json", "rule": null},
"keywords_per_group": {"value": 40, "from": "default", "rule": null},
"level_sizes": {"value": null, "from": "default", "rule": null}
}
No stage of cartolex’s declares seed today: the engine’s steps are
deterministic, and the layout takes the seed of its map version, which the
first build sets from params.json.
load_params(project) reads the file and refuses, with every reason at once, a
stage it does not know, a parameter a stage does not know, a value of the wrong
type, outside its range or not among its choices. A CrossCheck refuses a
value that is only impossible for this project, once the sizes it needs are
known: the plan marks the stage as unable to run, and a run that meets it
records a failed attempt with the reason. When a stage upstream in the same
build will report a size again (the vocabulary is being rebuilt), the plan
leaves the check to the run.
A ParamSpec also carries what the screens need, which never changes what a
value may be: its tier (essential, intermediate, advanced: how
prominent it is, from the one table PARAM_TIERS of stages.py, see
The tiers of the parameters), a widget when its type does not say its control
(order for provider_priority, levels for level_sizes; shape infers
the others), keys for a list that may be given per slot kind (parts,
doc_types: then its value is one list, or an object with a list, or null
when the parameter is nullable, for each kind) and suggestions, the items a
control offers for an open list. GET /api/params gives them with each
parameter (tier, widget, keys, suggestions).
stage |
parameter |
default |
allowed |
in a « Tune » panel |
|---|---|---|---|---|
|
|
rule |
title, abstract, body, full: one list for every slot, or an object giving each slot kind ( |
Texts |
|
|
rule |
document types: one list for every slot without its own, or an object giving each slot kind its own ( |
Texts |
|
|
folder, openalex, hal, scielo, europepmc, arxiv, biorxiv |
provider names; the others follow alphabetically |
Texts |
|
|
5 |
0–200; 0 keeps every year |
Texts |
|
|
25 |
1–1 000 characters of the normalised title: shorter titles never make two texts one work |
Texts › The same work |
|
|
1 |
0–50 years between two texts of the same title and a common author |
Texts › The same work |
|
|
person |
person, text, organisation |
Keywords |
|
|
3 |
≥ 1, and no more than the people whose texts build the lexicon |
Keywords |
|
|
3 |
≥ 1, and no more than the texts that build the lexicon: a candidate must occur in this many distinct texts (one co-authored text is one text’s evidence, however many people signed it) |
Keywords |
|
|
0.6 |
0.01–1 |
Keywords |
|
|
true |
true, false: candidates on cartolex’s list of rejections or in the machine’s cache go to the |
Keywords |
|
|
1 000 000 |
≥ 1: the window keeps the most used candidates of each language |
Keywords |
|
|
frequency |
frequency, presence, sublinear ( |
Keywords › Scoring |
|
|
2 |
0–20: α of |
Keywords › Scoring |
|
|
5 |
1–12 word units, prepositions and articles included |
Keywords › Scoring |
|
|
false |
true, false: English phrases with one « of » complement |
Keywords › Scoring |
|
|
1 |
0–1, or none (the rule off) |
Keywords › Bands |
|
|
0 |
0–1 |
Keywords › Bands |
|
|
1 |
0–1 |
Keywords › Bands |
|
|
0.5 |
0–1 (only when names are known) |
Keywords › Bands |
|
|
true |
true, false |
Keywords › Closed words |
|
|
true |
true, false (acts with |
Keywords › Closed words |
|
|
2 |
1–50 different closed words of another language (acts with |
Keywords › Closed words |
|
|
0.9 |
0–10, or none (the rule off) |
Keywords › Generic words |
|
|
0.2 |
0–1 |
Keywords › Generic words |
|
|
none (off) |
0–1, or none |
Keywords › Generic words |
|
|
false |
true, false |
Keywords |
|
|
10 000 |
≥ 10 |
Keywords |
|
|
1.3 |
1–100 |
Keywords |
|
|
[1, 4] |
two whole numbers, 1–12, not going down; the most grows to the longest form |
Keywords › Keywords of people and organisations |
|
|
tf |
tf, tfidf |
Keywords › Keywords of people and organisations |
|
|
none (every keyword a person uses) |
1–100 000, or none |
Keywords › Keywords of people and organisations |
|
|
50 |
1–10 000 |
Keywords › Keywords of people and organisations |
|
|
200 |
1–100 000 |
Keywords › Keywords of people and organisations |
|
|
rule |
person, text: what the space is fitted on (the texts by default; the people’s space may suit a corpus in several languages better, see Themes in the engine, at any depth) |
Space |
|
|
rule |
2–1000; a space never has more dimensions than people or keywords (the run says so) |
Space |
|
|
42 |
0–2³² − 1 |
Space › The SVD |
|
|
5 |
1–100 |
Space › The SVD |
|
|
randomized |
randomized, arpack (at as many dimensions as the matrix’s smaller side, the randomized solver runs, with a warning); above 500,000 texts ( |
Space › The SVD |
|
|
rule |
1–4 |
Grouping |
|
|
15 |
2–500, fewer than the kept keywords |
Grouping |
|
|
40 |
2–10 000; the levels must grow from the top |
Grouping |
|
|
none |
1 to 4 whole numbers, the groups per level from the top; when set, they replace |
Grouping |
|
|
50 |
2–1000 (at most the space’s dimensions are used) |
Grouping › Ward’s grouping |
|
|
15 000 |
2–1 000 000 points; exact Ward holds about 8·n² bytes |
Grouping › Ward’s grouping |
|
|
none: as many as |
2–1 000 000, or none |
Grouping › Ward’s grouping |
|
|
0 |
0–2³² − 1 |
Grouping › Ward’s grouping |
|
|
true |
true, false; true puts each keyword of the proposal on the level its texts support and sets aside the keywords too broad for any theme (Themes in the engine, at any depth) |
Grouping › The comb |
|
|
none: calibrated on |
0–1, or none |
Grouping › The comb |
|
|
rule |
1 to 100 numbers, 0–1 |
Grouping › The comb |
|
|
rule |
0–1 |
Grouping › The comb |
|
|
rule |
anywhere, within_parent (below the top level, only to a group under the same parent), up_only |
Grouping › The comb |
|
|
5 |
1–100 000 |
Grouping › The comb |
|
|
50 000 000 |
≥ 1 keywords × finest groups |
Grouping › The comb |
|
|
0.5 |
0–1 |
Grouping › Names |
|
|
8 |
1–500; the trajectories and the projected sets are placed alike |
Layout › Placing on the map |
|
|
0.25 |
0–10, a share of the map’s radius |
Layout › Placing on the map |
|
|
3 |
1–50 |
Layout |
|
|
1 |
1–1000 |
Layout |
|
|
|
|
Layout |
The layout of a map is not a build parameter: each map version keeps its own
method, seed and settings in decisions/maps.json, an input of map.layout.
map.layout’s own parameters say how points are placed on any map (the
keywords, and after it the time windows and the projected people).
The theme rules. With K kept keywords and U mapped units (the units the map
places, the mapped people by default), the depth of the theme tree is
min(⌊log₁₀ K⌋ − 1, ⌊log₁₀ U⌋), clamped to 1–4: 5 000 keywords on 800 people
give two levels (Theme › Topic). The top level has about top_groups groups,
the finest about keywords_per_group keywords per group, and the levels in
between grow geometrically: theme_level_sizes(50_000, 3, 15, 20) is
(15, 194, 2500). At depth 1 the one level follows top_groups.
The space’s dimensions. With P people whose texts build the lexicon, the space keeps 20 dimensions up to 2 000 people, then 20 × √(P / 2 000), at most 200: 8 000 people give 40, 100 000 give 141. A small project keeps the 20 it always had (both reference worlds); the measures behind the rule are in Sizes and machines.
Sizes. A rule reads ProjectSizes: the people whose texts build the
lexicon, their texts, the characters of those texts, the kept keywords and the
mapped units. Each is taken from the counts of the stage that provides it, in
its current run.json; before that stage has run, people, texts,
characters and mapped_units are estimated from the sources (the Parquet
metadata and people.csv), and a rule that needs a size nobody knows yet waits:
the plan shows its value as unknown, and the run computes it once the upstream
stage has reported it.
Run records and fingerprints¶
A run id is the UTC start time and six random hex digits
(20260928T101200Z-7c1e2a); ids sort by time. run.json (see
Derived results, caches, outputs and logs) holds:
inputs: the run id of each upstream stage read, and the fingerprint of each declared file that exists (a file that does not exist is not listed, so its appearance is a change too). Decision files are fingerprinted by the SHA-256 of their bytes. A Parquet table up to 64 MB likewise; a larger one by its size and its footer, which holds the schema and, for every row group and column, the row counts, byte sizes, offsets and statistics (a rewritten table is seen as changed without reading its data).identity: the values of the parts ofproject.jsonthe stage declares (languages,slots,ai,language_models…).code: the cartolex version, a SHA-256 over the source and data files of thecartolexpackage (the demo generator excepted), computed once per process, and the stage’s own version (Stage.version, from 1). A change of code alone leaves results up to date (StageStatus.code_changedsays so); a deliberate change of what a stage produces raises its version, and every result made by the earlier version then needs an update.measures: wall seconds, peak memory and counts. The counts include the stage’s cost driver (the size its cost grows with), so a later dry run can scale the measures to the project’s new size. The peak memory, on Linux, is the most the process and its worker processes held together during the stage: sampled every half second, each process by its proportional share (Pss, so that pages a forked worker or a mapped file shares count once), and at least the process’s own high-water mark, which the kernel resets when the stage starts. Elsewhere it is the process’s peak so far, an upper bound, plus the largest worker process that ran.
The six states¶
status(project) gives each stage one StageState, computed from its
run.json, its attempt record, its staging folders and the current
fingerprints, never from file dates:
running when a staging folder’s marker names the process that holds the project’s lock now;
skipped when the stage does not apply;
failed when an attempt record exists (the last attempt failed or was cancelled) or a staging folder was left by a killed process;
never built when there is no
run.json;needs update when something changed, with one
Reasonper change;up to date otherwise.
The reasons name what changed: the stage’s version (« cartolex changed how this
stage works (version 1 → 2) »), an input file (changed, added, removed), a
parameter (min_people: 3 → 2 (from params.json), year: 2026 → 2027 (default)), a part of project.json, or an upstream stage (rebuilt, without
results, now skipped, now with results, itself needing an update, failed or
running). A stage whose upstream stage needs an update needs one too.
A result computed by other code of the same stage version is not out of date:
StageStatus.code_changed says so, as information. A reader that opens a project while another process
swaps a stage may see that stage without results for an instant; a writer
never does.
Generations and safe re-runs¶
A stage writes only into its staging folder,
derived/.staging/<stage>.<run id>/, which holds its results and two things of
its own:
.staging.json: the run id, the process, the host and the project lock it runs under, the boot of the machine, and a key: a digest of the code and the stage’s version, the effective parameters, the upstream runs, the input fingerprints and theproject.jsonparts it reads;.chunks/: for a chunked stage,chunks.json(the number of chunks), a folder per chunk for its own files, and an empty<n>.donefile per finished chunk.
When the stage succeeds, cartolex.project.generations.swap_in writes the
journal derived/.journal.json (the stage, the new run, the run it replaces),
writes run.json into the staging folder, removes .staging.json and
.chunks/, moves derived/.previous/<stage>/ aside, moves derived/<stage>/
to derived/.previous/<stage>/, moves the staging folder to
derived/<stage>/, removes the attempt record, the journal, then the
generation moved aside. Each move is a rename inside derived/. A writer’s
Project.open(write=True) calls generations.recover: when the new generation
is whole (a run.json with the journal’s run id), the swap is completed;
otherwise it is undone and the previous results are put back. It also removes
what a swap moved aside and staging folders without a marker.
When a stage fails or is cancelled, its staging folder is removed and the
attempt is recorded in derived/.attempts/<stage>.json. When its process is
killed, the staging folder stays. The next run of the stage looks for it: when
the stage is chunked, its key matches (same code, parameters and inputs) and the
machine has not restarted since (checkpoints are not forced to disk), the run
continues in that folder and skips the chunks already done, and its run.json
says so in warnings; otherwise the folder is removed and the run starts over.
The dry run and the build¶
plan(project, targets=None, force=(), budget_mb=None) returns a BuildPlan:
one PlanItem per stage considered (the targets and every stage upstream of
them; every stage by default), in build order, with its action (run, keep,
skip), its reasons, its effective parameters as known now, an estimate of its
time and peak memory, whether it asks consent, whether it will resume a killed
run, and why it cannot run, if it cannot. A stage runs when it was never built,
needs an update, failed, is forced, or an upstream stage runs.
BuildPlan.describe() says all this in words.
An estimate scales the part of the stage’s last measures above its fixed part
by what the stage’s CostModel gives now over what it gave then (from the
sizes the run recorded in its counts), or, without a previous run, uses the
model itself: a fixed part plus a part per unit of the driver, to a power,
plus optionally a linear part of a second size (the extraction costs per text
as well as per character); a stand-in size while the driver is unknown (the
vocabulary from the people, twelve per person, at most 10 000). The cost
models of STAGES are fitted on each stage run in a fresh process
(tools/scale_study.py) on the demo worlds and on streamed worlds of 10³, 10⁴
and 10⁵ people (Sizes and machines), to the smallest worst ratio
(tools/cost_fit.py compares them with the measures): from 10³ to 10⁵ people
every stage’s estimate is within a factor of 2.25 of its measure, in time and
in peak memory (tests/test_build_costs.py holds the measures). The AI clean-up’s model is a guess, its cost being the provider’s.
A stage whose estimated peak memory exceeds the budget cannot run, nor can
anything downstream of it, unless build(allow_over_budget=True) or a list of
stage ids allows it. The budget is budget_mb, or by default the memory
available now (/proc/meminfo on Linux, vm_stat on macOS,
GlobalMemoryStatusEx on Windows) plus what the process already holds (read on
Linux; elsewhere nothing is added, which errs on the safe side), since a peak is
measured for the whole process.
build(project, targets, …) takes the same arguments, computes the same plan
and runs exactly its run items, one at a time, in order:
Consent. Before anything runs,
consent(ConsentRequest)is called for each stage that reaches the network or costs money, once. Without a callback, or on a refusal, an opt-in stage (the AI clean-up) is skipped for this build as if it were switched off: the plan is made again without it (plan(..., off=...)), it is listed with the skipped stages, and the stages after it run without it, as they do when it is off;params.jsondoes not change, so the next build asks again. Any other such stage, and everything downstream of it, does not run (BuildResult.refusedsays why); the rest runs.Progress.
progress(Progress)receives « phase k of n » events: the stage, its plain name, how far the stage is, how far the whole build is (weighted by the estimates) and a message. Neither fraction ever goes back, and a heartbeat repeats the last event whenever the stage has been silent forheartbeat_sseconds (5 by default, at most 10).Cancel.
cancelis athreading.Event, checked before each stage and before each chunk. The stage it stops removes its staging folder and records a cancelled attempt;BuildResult.summary()says « nothing changed » or « finished before the cancel » with the stages that did.Failure. A stage that raises removes its staging folder and records a failed attempt; the build stops there, and the stages after it do not run.
Log.
logs/jobs/<job id>.jsonlrecords the start, each phase, each stage’s end (run id, seconds, peak memory, counts), and the end: stage names, counts and times, never texts or names. The job id is a new run id, or thejob_ida job runner passes (letters, digits,-and_): the build then appends to the log the runner started.
One build runs at a time on a project: plan raises BuildBusy while a job
runs a stage it would run.
The engine on a project¶
cartolex.build.engine holds one runner per stage. A runner builds the engine’s
RunContext for its stage and calls the engine; the engine never learns that a
project exists (it imports neither cartolex.build nor cartolex.project).
stage |
the engine’s work |
|---|---|
|
|
|
extraction ( |
|
the AI triage ( |
|
consolidation ( |
|
the SVD space ( |
|
the term clustering ( |
|
|
|
the layout of the pinned map version ( |
|
|
|
each projected set projected with |
The figures and the portable bundle are outputs, not build stages.
What the computer gives. EngineOptions.budget (a cartolex.scale.Budget:
memory, worker processes, scratch folder; by default Budget.for_machine(), 40 %
of the memory up to 12 GB and three quarters of the processors less one) reaches
every stage’s RunContext: threads caps the worker processes and the memory
they may take (ThreadLimits.processes, memory_mb), extraction_n_jobs asks
for that many workers, and scratch holds the stages’ temporary files (else the
stage’s own folder). The results never depend on it (tests/test_build_workers.py
builds a project with one worker and with several and compares every file), so
it enters no fingerprint. cartolex build FOLDER --workers N --memory MB --scratch DIR sets it from the command line.
Where the engine’s files go. enginefiles.ENGINE_FILES gives every field of
EnginePaths a place: a file a stage writes (Owned, relative to its folder),
a file of the project (FromProject: the triage prompt override in
decisions/prompts/, the AI caches in cache/ai/, the parse cache in
cache/parse/), the running stage’s own folder (OwnFolder), or nothing
(NotProvided: a workspace’s operator files, the frozen atlas parameters, the
API key file, and the figures). engine_paths(stage, folders, root) points a stage’s own files into
its staging folder and every other file at its latest writer among the stages
the run may read (those upstream of it, directly or not, that have results);
a file with no such writer points into <staging>/.unavailable/, where nothing
exists, and a runner fails if the engine writes there. Three files are
amended: the layout adds its coordinates to the stored embeddings, refreshes
the clustered keyword table, and applies the themes again so they carry their
places on the map. map.layout starts from copies of the first two in its own
folder, and the stages after it read its versions. The tests check that every
field has exactly one place and that each stage folder holds only its own
files.
Settings. keywords_settings(project.json, …) gives the engine its corpus
slots (in the project’s order), languages and domain title; the parameters map
onto the engine’s settings; a stage reads the parameters of the stages before
it from their run.json, so it runs with the values the results it reads were
made with.
parameter |
engine setting |
|---|---|
|
|
|
|
|
|
the |
|
|
|
|
the rejection snapshot the runner writes ( |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
the theme levels |
every level: |
|
|
|
|
the pinned map version |
|
|
|
|
|
|
the stop-word profile: every word added or removed, in any language, extends or shrinks the list of words that are never keywords |
The engine builds every level of the depth the parameters give, from 1 to 4 (see Themes in the engine): a tree of one level has its keywords on its themes directly.
Map versions. Before the first layout, map.layout’s prepare adds and
pins map version v1 (layout umap, the seed of params.json). A rebuild uses
the pinned version; cartolex versions pins another or adds one with another
seed (--try-another --seed N) or another layout method (--method umap|tsne|tree,
which starts from that method’s defaults). A method the stage does not know, a
parameter its method does not take, or tsne without the optional openTSNE
package is refused with the reason (layouts). --dimensions 3 draws a
map in space (umap only; another method is refused), and --built (with
--try-another, or --built ID) marks a version to be built with the pinned one:
run_layout then draws every built version, the pinned one in the stage’s own
files and each other in versions/<id>/, and map.trajectories and
overlays.position place on each (derived files).
--not-built ID removes the mark.
The curated theme tree. Before themes.apply runs, its prepare rebases
decisions/themes.json onto the current vocabulary when it is based on
another one (cartolex.project.themes.rebase): a new keyword goes to the node
of the tree that holds a strict majority of the other keywords of its group in
the new proposal, looked for from the finest level up
(cartolex.build.engine.proposed_places), else aside, both marked « to
check », and the result is saved as a new version. The stage applies the tree
at its depth; at depth 2 it also converts it with
cartolex.project.themes_curated.to_curated into the two-level document and
applies that, for the two-level outputs.
A host’s options. engine_registry(ai, EngineOptions(prompt_dir=…, stopword_overlay=…)) gives every stage a host application’s prompt folder
(replacing the packaged templates) and its function words ({"add": {"en": […]}, "remove": {…}}), applied under each project’s own
decisions/stopwords.json, which wins where both name a word. Like the
packaged lists, they are the host’s code: changing them does not by itself
make a result out of date; force the stages that read them.
The AI clean-up needs an AIAccess: the provider’s key, or a client of
one’s own (client_factory, called like the provider SDK’s client; the tests
and the reference run answer with a fake model), and optionally the calls in
flight. engine_registry(AIAccess(...)) returns cartolex’s stages with it;
without one, the stage is refused with the reason. project.json must name the
provider (mistral) and model. Answers are cached in
cache/ai/triage_batch_cache.json and cache/ai/triage_term_cache.json, keyed
as always, so a forced second run asks nothing.
Progress and cancel. Every runner gives the engine’s RunContext a
progress callable (at most one event per percent) and a cancel callable
(the build’s event). Extraction, triage and consolidation report through them
and stop at their next report after a cancel; the SVD, clustering, layout and
trajectory steps report between their steps, and the trajectories per person
in their time windows. What cannot be interrupted: one layout fit, one SVD, one
clustering, and a single AI call in flight (the triage stops before the next).
Writing a runner¶
A runner is run(ctx: StageContext) -> Mapping[str, int] | None. It:
writes its results into
ctx.out, and nowhere else in the project;reads an upstream stage’s results from
ctx.folder(stage_id), the project’s files fromctx.layout, its parameters fromctx.params(effective values, withseedandyearwhen it declares them), the sizes fromctx.sizes, and theproject.jsonparts it declared fromctx.identity;reports progress with
ctx.progress(fraction, message)and warnings withctx.warn(message);in a long loop, iterates
for i in ctx.chunks(n):and keeps each chunk’s files inctx.chunk_dir(i); a chunk counts as done when the loop asks for the next one, and a cancel is checked before each chunk (ctx.check_cancel()checks at any other point);returns its counts, including the sizes it
provides.
Tests¶
tests/test_build_engine.py builds an XS demo project with cartolex’s own
stages, then checks the ownership table, the stages that need an update after
stopwords.json, params.json, people.csv or themes.json change, a
curated theme tree applied, the AI clean-up through an injected client (and
from its cache), a cancel and a killed process on real stages, and the command
line. The numeric reference is also run through a project build
(docs/dev/reference.md). tests/test_build_themes.py builds the S demo world
at depths 1 to 4 with the command line, checks at depth 2 that the tables of
any depth equal the two-level outputs exactly, and reads each depth’s map
bundle back.
tests/_build_fakes.py declares small fake stages on the real stage ids, each
writing a result that depends only on what it read and logging its calls
outside the project. The tests run the six states and their transitions, the
reasons, parameter validation and rules, the dry run against the run, consent
and budget refusals, progress and cancel, and kill a build process
(os._exit) at stage boundaries, inside a chunk and during a swap, then check
that every stage folder holds one whole generation and that the next build
gives the results of an uninterrupted one. tests/test_generations.py covers
every step of the swap and its recovery in both directions.