Decisions¶
decisions/ holds what people decided. Its files are small enough to read,
compare and edit, and they name things by stable keys: keywords by their text,
people and organisations by their ids. A re-extraction or a re-collection
never moves a decision onto something else; a decision whose key disappears is
listed, never dropped silently.
decisions/
people.csv roles, identities, merges
people_pairs.csv pairs of people judged two people, or left for later
organisation_pairs.csv the same, for organisations
organisations.csv levels, parents, merges, names, set by people
affiliations.csv affiliations added or removed by people
params.json the parameters people set
keywords.csv keep, exclude, merge, and each keyword's category
themes.json the theme tree, keyed by keyword text
maps.json map versions, the pinned one
snowball.csv collaborators proposed, round by round, and what was decided
stopwords.json additions to and removals from the function-word lists
curation-notes.md the curator's notes and standing rules for the AI copilot
prompts/ prompt overrides, one file per prompt
history/ every earlier version
Writing decisions¶
A writer passes the fingerprint (SHA-256) of the version it read. If the file
changed since, the write is refused and nothing is lost. An accepted write moves
the previous version to decisions/history/<file>/<UTC time>-<action>.<ext>
first, then writes atomically. project.json follows the same rule. Restoring a
version is a write like any other, so it is itself undoable.
A CSV decision file of 256 KB or more (the people.csv of a large project)
keeps most earlier versions as a delta instead,
<UTC time>-<action>.csv.delta: gzipped JSON
(format: "cartolex-history-delta/1"), saying how the latest earlier version kept
whole (of, its name) turns into this one. ops lists, in order, runs
[start, count] of that version’s records (a record is a line, or several when
a quoted field holds a line break) and the text of the records it lacks; base
and result are the SHA-256 of the version it starts from and of the version it
gives. One version in 50 is kept whole, and so is one of which more than half is
new. A delta depends only on files of the history, which never change: editing
the current file by hand leaves every earlier version readable.
people.csv¶
One row per person the project knows. Columns:
column |
meaning |
|---|---|
|
the key, as in |
|
|
|
for |
|
|
|
the service records that are this person, |
|
when two rows are one person, the |
|
free text |
|
UTC time of the last change to the row |
Merges. A merge is a decision, and it can be undone: the merged row keeps
its own role, identity and records, so emptying merged_into gives it back
as it was. While it is merged, the person it is merged into stands for both:
their records are their own and those of every row merged into them (each
counted when its row’s identity is confirmed or auto), and so are their
texts (a text both rows wrote counts once), their affiliations, their coverage
and their sheet; the collection asks the services for all those records, and
corpus.assemble reads the merged rows’ texts as theirs, never a merged row on
its own. merged_into names the person that remains; a chain (a row merged into
a row that is itself merged) is followed to its end, and a loop leaves its rows
on their own. Two rows whose ORCIDs differ are not merged unless someone says
they are one person anyway. corpus.assemble records the merges as an input of
their own (decisions/people.csv#merges, in its run.json), present only when
there are merges.
people_pairs.csv and organisation_pairs.csv¶
Pairs proposed as one person (one organisation) and judged otherwise, so that
the proposal does not come back. Columns: a, b (the two ids, the smaller
first), decision (distinct: two people, never proposed again; later: not
decided, kept in the list of pairs to review and out of every automatic merge),
note, decided_at. A merge is not a pair decision: it is merged_into.
Undoing a merge may record its pair here.
Keywords are decided by the mapped people: a keyword is kept only if enough
mapped people use it. context texts count in the statistics, never in that
threshold.
organisations.csv and affiliations.csv¶
organisations.csv changes what the sources say about organisations: org_id,
level, parents (;-separated), name, merged_into, note, decided_at.
An empty cell leaves the source’s value. affiliations.csv adds or removes
affiliations: person_id, org_id, start_year, end_year, action
(add or remove), note, decided_at; a removal without start_year
removes every affiliation of the person to the organisation, with one only
those starting that year.
Every reader applies them (cartolex.project.organisations): the corpus (the
unit of each person), the organisations’ list and detail, the people’s units, a
person’s sheet, the coverage by organisation and the atlas. A merged
organisation is read as the one it is merged into: its affiliations are that
organisation’s, it is not listed on its own, and a parent merged elsewhere is
replaced by the one that remains. A merge can be undone: emptying merged_into
gives the organisation back as it was.
params.json¶
Only the parameters people set. Every other value comes from a default, most of
them computed from the project’s sizes by a simple rule; the effective value and
where it came from are written into each stage’s run.json.
{
"format": "cartolex-params/1",
"seed": 20260928,
"pinned_year": null,
"stages": {
"keywords.extract": {"counting_unit": "person"},
"themes.group": {"top_groups": 15}
}
}
seed fixes every random choice; pinned_year fixes the year that date windows
count back from (none: the current year). A parameter a stage does not know, or
an impossible value (as many topics as keywords), is refused when the file is
read, with the reason.
collect (optional, added within version 1 and left out of the file when
empty) holds the collection’s parameters people set, by step, each a whole
number:
"collect": {"snowball": {"cap": 200, "max_authors": 25}, "coverage": {"good": 3}}
parameter |
default |
meaning |
|---|---|---|
|
200 |
collaborators’ rounds are taken whole while the people proposed stay within this number; the round that would pass it is left out, and named |
|
25 |
a work with more authors is left out of the co-author graph |
|
3 |
a person is well covered from this many texts with an abstract (or a full text) |
An unknown step or parameter, or a value below its smallest (1, and 2 for
max_authors), is refused when the file is read.
ai (optional, added within version 1 and left out of the file when empty)
holds the route people chose for each AI step of a build, from the build
page:
"ai": {"keywords.triage": "copilot", "themes.curation": "none"}
step |
routes |
meaning |
|---|---|---|
|
|
the keyword clean-up, between the extraction and the vocabulary |
|
|
the theme curation, between the grouping and the themes applied; it has no API route |
none (the default) builds without AI help. copilot: the build runs up to
the step and pauses there until an assistant’s result is imported and
accepted, or the person continues without it. api: the clean-up runs by the
provider’s API, the opt-in stage keywords.triage, whose enabled switch
(stages) is the one the build reads: choosing api sets it, choosing
another route clears it, and enabled: true reads as api whatever ai
says. An unknown step or route is refused when the file is read.
similarity (optional, added within version 1 and left out of the file at its
default space) is how two people, or two organisations, are compared: the
headline of Compare on the map, the nearest of each and the exported
distances. It needs no rebuild.
value |
meaning |
|---|---|
|
the cosine of their vectors in the space of the themes (the default) |
|
the cosine of their keyword profiles (each keyword’s share of their use) |
|
the share of their keywords both use, among those either uses |
|
Σ min of their top-level theme shares |
keywords.csv¶
column |
meaning |
|---|---|
|
the keyword, as shown |
|
its language |
|
|
|
for |
|
why, in words |
|
|
|
UTC time |
|
optional (added within version 1, written only when a row fills it): what a kept or merged keyword names, |
A proposal imported from an AI (a copilot’s result, or, from an earlier
version, an answer to a handoff) is kept as it came in history/ai/, and only
the changes someone accepts reach keywords.csv.
The curator’s notes for the AI copilot: curation-notes.md¶
A Markdown file the curator writes (Settings › Project, or either copilot
dialog): teams, keywords that belong together or apart, standing context.
Under the heading ## Standing rules for the AI copilot, one line per rule
agreed with a copilot, with the task it is for:
Two teams work on the same coast: keep their waves and tides together.
## Standing rules for the AI copilot
- triage: research discourse is always excluded, sure
- themes: never merge the two policy themes
Every copilot bundle carries the notes and its task’s rules, as context for the assistant (never instructions); importing a result adds the new rules it brought. It is written like every decision file (the version read, the history); a line under the heading that is not a rule is kept in the notes.
Rejected automatically: cartolex-rejects/1¶
Some candidates are never keywords, in any field. Two lists name them, and the
extraction puts the candidates they name in the rejected band (never judged
by an AI, never in the lexicon):
cartolex’s list, shipped with the package in
cartolex/_data/rejects/<lang>.json:{"format": "cartolex-rejects/1", "language": "en", "terms": ["…"]};this computer’s cache, in the app’s own folder (
<data dir>/rejects/<lang>.jsonl, never in a project): a first line{"format": "cartolex-rejects/1", "language": "en"}, then one JSON object per term and project:term,language,route(ai-api,ai-handoff,ai-copilot),date(UTC day),project(a fingerprint of the project, never its name). An AI’sneveranswers enter it: every one by API (its prompt gives K, G and F only when sure), and the accepted exclusions of a handoff or a copilot that the judge markedsure(an answer that does not say isunsure, and stays out).
A project’s own answers never reject its own candidates: only other projects’
do. A person’s decision on a term wins: the term is not rejected in that
project, and a keep or a merge (a put back) takes it out of the cache. The
rejects parameter of keywords.extract (params.json) switches both lists
off for a project. A change of the cache makes no result out of date: it
applies at the next extraction (force keywords.extract to apply it now).
The extraction records what it applied in its results
(derived/keywords.extract/rejects.json: per language, each term and its
origin, list or earlier).
cartolex rejects export [--min-projects 2] [--out folder] [--projects …] makes
a list to ship from the cache: the terms seen in at least that many projects,
without any that looks like a proper name (a capital letter, a digit) or holds a
word of a person’s or an organisation’s name in the user’s projects (the
projects given, else those the app opened lately). The maintainers review every
term before adding a list to the package; the vocabulary scan
(tools/vocab_scan.py) covers it like every file of the repository.
themes.json: the theme tree¶
The tree has a depth from 1 to 4 levels of nodes. Its nodes have stable ids and names in each interface language; each keyword text sits on one node, at any level: a keyword on a higher node is broader than every node below it.
{
"format": "cartolex-themes/1",
"depth": 2,
"levels": [
{"names": {"en": "Theme", "fr": "Thème", "pt": "Tema"}},
{"names": {"en": "Topic", "fr": "Sujet", "pt": "Tópico"}}
],
"nodes": [
{"id": "n1", "parent": null, "names": {"en": "Coastal hazards"}, "order": 1},
{"id": "n7", "parent": "n1", "names": {"en": "Storm surge"}, "order": 1}
],
"keywords": {"coastal flooding": "n7", "extreme events": "n1", "storm surge model": "n7",
"tidal inlet": "n7", "wave climate": "n7"},
"attribution": {"wave climate": 1},
"set_aside": {"numerical results": {"from": "n7", "reason": "too general", "attribution": 0}},
"review": {"tidal inlet": "to_check"},
"based_on": {"run": "themes.group/20260928T101500Z-3f2a", "vocabulary": "sha256:…"},
"saved": {"at": "2026-09-28T10:20:00Z", "action": "move 3 keywords to n7"}
}
key |
meaning |
|---|---|
|
the number of levels (1 to 4) and their names, from the top level down |
|
every node: a stable |
|
each placed keyword and the node it is on, at any level |
|
for a placed keyword that does not count at its node’s level: how many levels, from the top, its usage counts toward (see below) |
|
each keyword set aside: |
|
|
|
the run and the vocabulary the tree was built or last rebased on |
|
when this version was saved, and the action that saved it |
Every keyword of the current vocabulary is on exactly one node or set aside, never both;
reviewnames only keywords the tree holds.A node’s level is its distance from the top (1 for a top-level node); no node sits below the tree’s depth. A node may hold keywords and child nodes at the same time. In the example, « extreme events » sits on the theme itself: it belongs to coastal hazards, not to storm surge in particular.
Attribution. A keyword’s usage counts toward its node and every node above it (a keyword on a top-level node counts toward that node only).
attributionlowers that:ncounts it toward levels 1 tononly,0shows the keyword without counting it anywhere, and a keyword absent from the map counts down to its node’s level.nis below the level of the keyword’s node (0 to level − 1), and only placed keywords appear. In the example, « wave climate » is shown under storm surge but counts toward coastal hazards only.The carry rule. A move, a merge or a split that gives a keyword another node drops an attribution other than
0;0always stays. A keyword that keeps its node keeps its attribution (moving a node moves its keywords with it). A set-aside keyword keeps its attribution in its entry; putting it back on the node it came from gives it back, anywhere else only0comes back.Depth changes keep keywords on their nodes; the nodes’ levels shift. Inserting a level gives each node just above it that has child nodes one new child, named like it, that takes them over (at the top: one new root over every top-level node; at the bottom: nothing, the level starts empty), and an attribution counting toward the new level’s position becomes
n + 1. Removing a level dissolves its nodes into their parents, which receive their child nodes and keywords; the keywords of a removed top-level node are set aside with the reasonits node was removed with its level. An attribution counting toward the removed level becomesn − 1(a keyword that counted toward the removed top level only then counts nowhere); a keyword moved to the parent withnequal to the parent’s level loses its attribution.frommay name a node that no longer exists (merged away, or removed by a rebase or a save); putting the keyword back then needs a target. cartolex never gives a new node such an id.cartolex writes the file in one form: nodes in tree order (depth first, siblings by
orderthen id), keywords, attributions, set-aside keywords and review states sorted by text; a set-aside entry without an attribution has noattributionkey.Saving removes empty nodes. Every save removes each node with no keyword in its subtree, itself included (set-aside keywords do not count), and names the removed nodes in its action (
move 2 keywords to n7; remove empty nodes n3, n5). A saved file therefore never holds an empty node; a tree being edited may, until it is saved.based_on.vocabularyis the fingerprint of a vocabulary:sha256:and the SHA-256 of the JSON list of its distinct keywords, sorted, written without spaces (["a","b"]) in UTF-8.After a new extraction or a new grouping the tree is rebased onto the new vocabulary: kept keywords stay where they are (set-aside ones stay set aside); each new keyword goes to the node proposed for it, at any level, marked
to_check(set aside, with the reasonnew keyword, no place proposed, when no node is proposed); vanished keywords are removed, set-aside ones too; a node whose subtree held keywords and holds none after the rebase is removed, with the nodes under it. Nothing else changes: no node is renamed, moved or renumbered, and surviving keywords keep their attribution. The rebase is saved as a new version, and its reconciliation list names every added or removed keyword and every removed node. That list is the difference between the version before the rebase and the version it wrote, so the history keeps it.Level names are the defaults for the depth (Theme; Theme › Topic; Field › Theme › Topic; Domain › Field › Theme › Topic, in English, French and Portuguese) until someone renames them. When the depth changes, a name still equal to its default follows the defaults of the new depth; a name someone gave stays.
Every change is a new version:
savedrecords when and by which action it was saved, anddecisions/history/themes.json/<UTC time>-<action>.jsonholds the version that<action>replaced at that time. Restoring a version saves it again as a new version, with the actionrestore <version>.
The operations on a tree and their rules are described in The theme tree.
maps.json: map versions¶
{
"format": "cartolex-maps/1",
"pinned": "v3",
"versions": [
{"id": "v3", "shows": ["people"], "layout": {"method": "umap", "seed": 7}, "base": null,
"created_at": "2026-09-28T11:00:00Z", "note": "after the September curation"},
{"id": "v4", "shows": ["people"],
"layout": {"method": "umap", "seed": 8, "dimensions": 3}, "base": null,
"created_at": "2026-10-07T09:00:00Z", "note": "in space", "built": true}
]
}
shows lists what the map shows: people, organisations:<level>, texts,
possibly several. base names a base from project.json when the version is
placed on another project’s map. A rebuild keeps the pinned version’s layout;
another layout is tried as a new version beside it.
layout.dimensions is 2 (a flat map, the default) or 3 (a map in space): only
the umap method draws in space, and not with its flat recipes (layout:
tsne_anchored, tsne, tree); a version that asks for it otherwise is refused
when the file is read. built (default false) marks a version to be built with
the pinned one: each build of the map draws every built version and places the
people, keywords, organisations, texts, projected people and time windows on it
(see Derived results, caches, outputs and logs). The pinned version is always built, whatever its mark, and
stays the reference: distances, exports and the themes’ places come from it.
layout.method is one of:
method |
the people’s map |
|
|---|---|---|
|
UMAP of the people in the space |
|
|
t-SNE of the people (the optional |
|
|
the applied theme tree: themes as discs, people inside their heaviest theme |
|
In every method the keywords are placed on the people’s map by their nearest
people (map.layout’s neighbours and link_radius in params.json), and
seed makes the map the same on every run. Layouts compared
compares them.
snowball.csv¶
One row per collaborator proposed: round, person_id, seeds (the seeds they
wrote with, ;-separated), path (how they connect to a seed), joint_texts,
last_joint_year, fit, decision (mapped, context, projected, no,
later), decided_at. A round is taken whole or not at all, up to the cap set
in params.json (collect.snowball.cap). seeds names the people of the round
before (the seeds, for round 1) the collaborator wrote with, path the chain of
person_ids from a seed to them, joined by >, and fit their topical fit to
the seeds (0 to 1, see Collecting people and texts). A collaborator is proposed with the
decision context (their texts shape the lexicon, they are not on the map);
no makes them excluded in people.csv, later undecided.
stopwords.json and prompts/¶
stopwords.json adds to or removes from the packaged function-word lists, per
language: {"format": "cartolex-stopwords/1", "add": {"fr": ["…"]}, "remove": {}}. prompts/<name>.txt replaces a packaged prompt; it must keep the
placeholders the packaged prompt uses, or it is refused when read.