zettelkasten.graph_io¶
zettelkasten.graph_io ¶
Loader/writer free functions for the Zettelkasten store.
Split out of :mod:zettelkasten.graph along the data-model / IO seam: the model
(Note/Link/ZettelGraph and serialization) stays in graph.py,
while the atomic-write helpers and the special-folder loaders/writers
(_meta.yaml, _citations/, _projects/, _reviews/) live here.
Every name defined here is also re-exported from zettelkasten.graph (with
identity preserved) so existing from zettelkasten.graph import ... callers
keep working unchanged.
Circular-import note: this module must NOT import zettelkasten.graph at
top level. graph.py imports this module at the bottom (after the model
classes are defined) to build its re-export shims; if this module imported
graph at top level, the graph-defines-shims step would race a
half-initialized graph_io in the import graph_io-first order. Instead we
reach the model-owning module lazily at CALL time via :func:_graph — which
also means GRAPHS_DIR monkeypatched on zettelkasten.graph is observed
here, preserving the previous single-module behaviour.
ExtractionContextNotFound ¶
Bases: Exception
A FULL extraction context was supplied but no keyed entry matched.
Raised by :func:resolve_extraction_context when a FULL context key is
demanded (BOTH project AND synthesis_graph supplied) AND the source
carries a NON-EMPTY contexts list yet none of its entries match the
requested (project, synthesis_graph) pair. With a populated contexts
list, silently falling back to the flat block would enforce the write against
ANOTHER context's rubric (the original multi-context P1 we must not regress),
so this fails loud instead. Callers (add_note / attach_to_dimension /
coverage) catch this and surface a clean, structured tool error rather than
crashing the run.
This is NOT raised for a PARTIAL pair (exactly one of project /
synthesis_graph supplied): a partial pair can NEVER match a keyed entry,
so failing loud would buy nothing and only break the legitimate no-structure
call (which passes NEITHER key) when it accidentally carries one half. A
partial pair degrades to the flat block instead -- treated exactly like a
no-context read. It is likewise NOT raised for a source with NO contexts
list (pure-legacy / flat-only meta, or a best-effort keyed write that never
landed): such a source degrades gracefully to its flat block exactly as
before. A miss with NO context supplied is likewise not this error.
Source code in zettelkasten/graph_io.py
atomic_write_text ¶
Write text to path crash-safely (temp file + fsync + rename).
The temp file is created in the SAME directory as path so the final
os.replace is an atomic rename on the same filesystem. The file's
contents are flushed and os.fsync'd before the rename, and the PARENT
DIRECTORY is fsync'd after, so a crash can never leave a half-written
manifest/note nor lose the directory entry of a newly-created file. The temp
file is cleaned up on failure. Mirrors the temp+rename precedent in
config.write_config and embeddings.save_cache, adding the fsyncs for
durability.
Source code in zettelkasten/graph_io.py
write_yaml_atomic ¶
Dump data to YAML and write it atomically via :func:atomic_write_text.
Uses default_flow_style=False, sort_keys=False to match the existing
manifest/meta YAML style in this package.
Source code in zettelkasten/graph_io.py
load_source_meta ¶
Load a source graph's _meta.yaml as a dict (empty if absent).
Centralizes the bibliographic/coverage metadata read that several callers
previously did inline. Returns {} for a missing file or malformed YAML
so callers can treat metadata as best-effort.
Source code in zettelkasten/graph_io.py
parse_date_int ¶
Coerce a source date value to an inclusive-comparable YYYYMMDD int.
Accepts a datetime.date/datetime.datetime (YAML parses date:
2026-01-15 to a date), an ISO-ish string (2026-01-15, 2026/01,
2026), or an int (20260115 passed through; a bare 2026 treated as
a year). Returns 0 when nothing parseable is present.
Coarse values fill their missing components with the START of the period by
default (year 2026 -> 20260101, month 2026-03 -> 20260301).
Pass end=True to fill with the END instead (20261231 / 20260331),
so a date-range upper bound built from a year/month is inclusive of the
whole period. (Day is clamped to 31 for an end bound; the store's dates
are real calendar days, so a numeric <= YYYYMM31 comparison is correct.)
Source code in zettelkasten/graph_io.py
source_date_int ¶
A source box's date as a YYYYMMDD int, 0 when unknown.
Reads the box's _meta.yaml and prefers a fine date field (finer than
the coarse year), falling back to year so existing corpora that only
carry a publication year still get a usable (year-granular) date. Used both to
stamp the global ANN index and to drive the calendar-recency rerank channel;
all notes in a box share the source date, so this is one lookup per box.
Source code in zettelkasten/graph_io.py
write_coverage ¶
write_coverage(source: str, *, agent: str = '', human: str = '', graphs_dir: Path | None = None) -> dict
Set a source's coverage.{agent,human} levels in its _meta.yaml.
The single coverage-write seam, shared by the set_coverage MCP tool and
the dashboard's coverage endpoint so the two can never diverge. An empty
agent/human leaves that axis unchanged; both default to "none"
when the source has no recorded coverage yet. Returns the resulting coverage
dict.
Raises ValueError for a level outside :data:COVERAGE_LEVELS and
FileNotFoundError for a source with no _meta.yaml (i.e. not a real
source folder). The source name is path-jailed via :func:safe_join.
Source code in zettelkasten/graph_io.py
write_extraction_meta ¶
write_extraction_meta(source: str, *, project: str | None = None, synthesis_graph: str | None = None, schema: str = '', strict: bool | None = None, grounded: bool | None = None, tags: list[str] | None = None, structure: dict | None = None, graphs_dir: Path | None = None) -> dict
Persist a grounded-extraction run's rubric onto a source's _meta.yaml.
Writes (and shallow-merges into) an extraction block so the zettelkasten
server can enforce the run's contract at WRITE time -- the seam that makes
strict-tag enforcement (add_note), deterministic spine attachment
(attach_to_dimension), and the coverage matrix (source coverage)
model-independent rather than reliant on the agent doing the right thing.
A single source can be extracted under MULTIPLE contexts -- several schemas
in one project (each minting its own synthesis spine) and/or several projects
over the SAME source graph. A single flat extraction block keyed only by
the source name made these contexts clobber each other last-write-wins. So
the block is now a COLLECTION keyed by (project, synthesis_graph):
extraction:
# legacy flat keys (the no-context write target + read fallback)
schema: ... / strict: ... / grounded: ... / tags: [...] / structure: {...}
# one record per (project, synthesis_graph) context
contexts:
- {project, synthesis_graph, schema, strict, grounded, tags, structure}
- ...
Behaviour:
- When BOTH
projectandsynthesis_graphare given, the fields are written/merged into the matchingcontextsrecord (matched on the pair). A record created for the first time starts EMPTY (only its identity key) and is NOT seeded from the flat block: a keyed entry must carry ITS OWN schema's rubric, written by this run's keyed writes, not whatever the (possibly another context's) flat block currently holds. An EXISTING record is merged in place (no duplicate appended). When the MERGED POST-WRITE entry HOLDS astructure(it carried one before, or this write added one), it is mirrored into the flat block (the no-context fallback view) with PERMISSIVE per-leaf semantics:structureLAST-WRITE-WINS (so a no-contextattach_to_dimensionresolves the most recent spine);tagsare UNIONED into the flat block's existing tags (order-preserving dedup) so the flat block ACCUMULATES the full vocabulary across every context and a context-less off-spine claim LANDS rather than being rejected;schema/strict/groundedare PRESERVED when the flat block already holds them (only set from the keyed entry when absent), keeping the named-schema fallback rubricprep_sourceswrote. Gating on the merged entry (rather than on this call passingstructure) keeps the promoted-spine TWO-STEP write working: a later rubric-only write (reconcile_meta_tags, nostructurearg) over a context whose earlier write already landed a structure RE-FIRES the mirror so the spine's tags join the flat union. A rubric-only keyed write to a context that NEVER had a structure leaves the flat block untouched, preserving a still-flat context's own tags. The union only ever GROWS the flat allowed set, so it can never reject a previously-valid claim (the original multi-schema coherence bug stays fixed). - When context is ABSENT (either is falsy) the LEGACY FLAT block is written/ merged EXACTLY as before -- mandatory back-compat for existing on-disk meta and the no-context callers/tests.
Each call only overwrites the keys it is given (a None/empty value leaves
the existing key untouched), within whichever record it targets. Returns the
record that was written (the flat block, or the context entry).
Raises FileNotFoundError for a source with no _meta.yaml (i.e. not a
real source folder). The source name is path-jailed via :func:safe_join,
mirroring :func:write_coverage.
Source code in zettelkasten/graph_io.py
335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 | |
prune_extraction_context ¶
prune_extraction_context(source: str, *, project: str, synthesis_graph: str, graphs_dir: Path | None = None) -> bool
Remove an ORPHANED non-enforcing keyed (project, synthesis_graph) context.
Cleans up the structure-only keyed block that
:func:coordinator.extraction.materialize_structure writes OPTIMISTICALLY,
before reconcile decides membership. When a spine ends up UNUSED (no source
joined its enforcing rubric) -- or a single source is DEMOTED to flat for a
surviving spine -- that keyed context entry is dead weight; the run's
reconcile step prunes it here.
Scope is deliberately narrow and SAFE:
- Removes ONLY the matching
contextsentry, and ONLY when it is a NON-ENFORCING orphan -- i.e. it carries NO truthystrictrubric. An entry holdingstrictwas enrolled by some run's ENFORCING keyed write (possibly a PRIOR run over the same source); removing it would strand a real enrollment, so it is PRESERVED and this returnsFalse. (Unused spines only arise on strict/enforcing runs, so this guard is what makes cross-run pruning safe.) - The PERMISSIVE flat tag union is LEFT INTACT -- it only ever grows by design (so a context-less off-spine claim still lands flat); pruning a dead spine's bookkeeping must never silently tighten the flat substrate.
- The flat
structuremirror (the no-contextattach_to_dimensionfallback) is re-pointed to the most-recent REMAINING structure-bearing context, or dropped when none remains, so a no-context attach never resolves against the pruned spine.
Best-effort: a missing _meta.yaml, no extraction block, an absent
context, or an enforcing match is a silent no-op. Returns True iff an
entry was removed.
Source code in zettelkasten/graph_io.py
506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 | |
resolve_extraction_context ¶
resolve_extraction_context(source: str, project: str | None = None, synthesis_graph: str | None = None, graphs_dir: Path | None = None) -> dict | None
Resolve the extraction record governing a write in a given context.
Returns the (project, synthesis_graph) context entry when one matches. The
returned dict carries the leaf fields (schema/strict/grounded/
tags/structure) callers read.
The resolution rule -- which reconciles the no-silent-loss requirement with
back-compat -- keys on whether the source carries a NON-EMPTY contexts
list and whether a FULL context key was demanded. A key is "full" only when
BOTH project AND synthesis_graph are supplied; a PARTIAL pair (exactly
one of the two) can never match a keyed entry, so it degrades to the flat
block exactly like a no-context read rather than failing loud (failing loud on
a partial buys nothing -- it would only break the legitimate no-structure call
that accidentally carries one half).
- Non-empty
contextslist + FULL key whose(project, synthesis_graph)IS present -> return that entry. - Non-empty
contextslist + FULL key that is ABSENT -> RAISE :class:ExtractionContextNotFound. Falling back to the flat block here would silently enforce the WRONG rubric (the multi-context P1 we must not regress). - PARTIAL key (exactly one half supplied) -> FALL BACK to the flat block (it
can never match a keyed entry; treated as a no-context read), even against a
populated
contextslist. This is what lets a no-structure run that passes a strayprojectover a multi-context source still enforce flat. - NO
contextslist (empty or absent) -> pure-legacy / flat-only source (or a best-effort keyed write that failed): FALL BACK to the flat block, even under a full key. This degrades to flat enforcement exactly like pre-keyed behaviour rather than hard-rejecting every claim. - NO context supplied (legacy no-context read) -> fall back to the flat leaves so pre-keyed meta and no-context callers behave exactly as before.
Returns None when the source has no extraction meta at all (or only an
empty block) -- including under a full key, so a graph with no extraction
rubric (e.g. _cross) correctly SKIPS enforcement rather than failing loud.
Source code in zettelkasten/graph_io.py
605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 | |
mark_slices_extracted ¶
mark_slices_extracted(source: str, slices, *, project: str | None = None, synthesis_graph: str | None = None, schema: str = '', content_hash: str = '', run_id: str = '', graphs_dir: Path | None = None) -> dict
Record that slices of source have been extracted, on its _meta.yaml.
Appends the given spans to a completed_slices list inside the source's
extraction block — the per-slice incremental-extraction ledger the
efficiency pipeline reads (via :func:read_completed_slices) to skip
already-mined regions. Each stored entry is a dict
{char_start, char_end, page_start, page_end, label, kind, schema,
content_hash, at, run_id} (at = ISO-8601 UTC timestamp).
Keying mirrors :func:write_extraction_meta: when BOTH project AND
synthesis_graph are given the ledger is written onto the matching keyed
(project, synthesis_graph) contexts entry (created empty if absent);
otherwise it is written onto the legacy flat block. A partial pair (only one
half) degrades to the flat block, exactly like a no-context write.
schema is stored on each row so the ledger is SCHEMA-AWARE: stamping a
source done under schema A must not make a later schema-B run skip it. It is
ALSO part of the span identity (below), so the same span extracted under two
schemas keeps two independent rows rather than one clobbering the other (which
matters when both land on the flat block). A blank schema reproduces the
legacy schema-less row exactly. kind (e.g. "full") is persisted from
the span so a whole-document slice round-trips even if the stamping agent drops
its char_start=0 (see :func:coordinator.extraction._span_bounds).
Dedup is on SPAN IDENTITY (see :func:_slice_span_identity): re-marking a
span already in the ledger REFRESHES that row's provenance
(content_hash / at / run_id) in place rather than appending a
duplicate — so a region re-extracted under a changed content_hash updates
to the new hash instead of leaving a stale row that
:func:read_completed_slices would filter out. Insertion order is preserved.
slices entries that are not dicts are ignored. Returns the record written
(the flat block, or the keyed context entry). Raises FileNotFoundError
when the source has no _meta.yaml; the source name is path-jailed via
:func:safe_join, mirroring :func:write_extraction_meta.
Source code in zettelkasten/graph_io.py
805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 | |
read_completed_slices ¶
read_completed_slices(source: str, *, project: str | None = None, synthesis_graph: str | None = None, schema: str = '', content_hash: str = '', graphs_dir: Path | None = None) -> list[dict]
Return the completed-slice ledger for (source, context, schema).
The read counterpart of :func:mark_slices_extracted, keyed identically: a
FULL (project, synthesis_graph) pair reads the matching keyed context
entry; no context (or a partial pair) reads the flat block.
schema makes the read SCHEMA-AWARE so one schema never consumes another's
completed slices. The match rule preserves back-compat with legacy
(schema-less) ledgers:
- A blank
schema(caller doesn't care) returns every row regardless of its stored schema — identical to the pre-schema behaviour. - A non-blank
schemareturns rows whose storedschemaequals it, PLUS rows with NO stored schema (legacy rows written before schema-awareness are shared by every schema so existing ledgers keep skipping). A row stamped with a DIFFERENT non-blank schema is filtered out — that is the isolation fix.
When content_hash is non-empty, ONLY entries whose stored content_hash
matches are returned — a changed source hash invalidates stale ledger rows, so
a re-hashed source is correctly seen as not-yet-extracted. An empty
content_hash returns every recorded span regardless of hash.
Returns [] for a missing _meta.yaml, a source with no extraction
block, a keyed context with no matching entry, or an empty/absent ledger.
Source code in zettelkasten/graph_io.py
write_source_doi ¶
Set a promoted source's bibliographic doi in its _meta.yaml.
The single DOI-write seam used by the dashboard's source-doi endpoint. The
value is normalized to bare 10.xxxx/... form (URL/doi: prefixes
stripped) via :func:normalize_doi; an empty/whitespace input clears the
field. Returns the stored (normalized) value.
Raises FileNotFoundError for a source with no _meta.yaml (i.e. not a
real source folder). The source name is path-jailed via :func:safe_join,
mirroring :func:write_coverage.
Source code in zettelkasten/graph_io.py
load_citations ¶
Load all citation entities from _citations/ as a dict keyed by ID.
Citation files are YAML (not markdown notes). Each file represents a lightweight bibliographic entity that notes can link to via graph="_citations".
Source code in zettelkasten/graph_io.py
normalize_doi ¶
Normalize a DOI to a bare lowercase 10.xxxx/... form.
Strips common URL/scheme prefixes so the same DOI written different ways
(https://doi.org/10.x, doi:10.X) collapses to one key. Returns ""
for empty/invalid input.
Source code in zettelkasten/graph_io.py
canonical_citation_key ¶
Compute a dedup fingerprint for a citation/source-like dict.
Priority: DOI > arXiv id > normalized (first-author-lastname + year + leading title words). Two records with the same fingerprint are considered the same work. The fingerprint is not the storage id — it is only used to detect duplicates.
Source code in zettelkasten/graph_io.py
note_graph_names ¶
List note-graph folder names: every source folder plus _cross.
Excludes the non-note special folders (_citations, _projects,
_reviews). Used by maintenance tools that must scan/rewrite links across
all graphs.
Source code in zettelkasten/graph_io.py
load_project ¶
Load a single project manifest from _projects/
Returns {} for a missing file (no exception). A corrupt manifest
(invalid YAML, non-UTF-8 bytes, unreadable inode) is NOT silently swallowed:
unlike the missing case it would mask real data loss, so it is re-raised as a
clear, catchable ValueError that the route layer can map to a clean
status instead of a raw 500 traceback — mirroring :func:load_review.
Source code in zettelkasten/graph_io.py
list_projects ¶
List all project manifests from _projects/.
A single poison file (invalid YAML, non-UTF-8 bytes, unreadable inode) must
not 500 the whole list, so the offender is skipped and the rest keep
loading — mirroring :func:load_citations.
Source code in zettelkasten/graph_io.py
project_cross_ids ¶
Explicit _cross note ids a project claims (the manifest cross: list).
Membership of a synthesis note in a project is now EXPLICIT (curated on the
manifest) rather than inferred from the link graph. Returns a de-duplicated,
order-preserving list of non-empty string ids; tolerates a missing/malformed
field by returning [] so a hand-edited manifest never 500s a read.
Source code in zettelkasten/graph_io.py
project_default_spine ¶
The org id of a project's default (primary) organizing spine, or None.
The manifest default_spine field names the spine-directory entry (a
project-owned org id) the project organizes by — parallel to the cross:
whitelist (:func:project_cross_ids), but for organizing STRUCTURES rather
than synthesis NOTES. None (unset / missing / malformed) means the implicit
intrinsic lens — the organic cluster graph — which has NO org record. A
blank or non-string value is treated as unset, so a hand-edited manifest never
500s a read.
Source code in zettelkasten/graph_io.py
cross_note_connects ¶
cross_note_connects(note, source_set: set[str], project_note_ids: set[str], referenced_cross: set[str]) -> bool
Whether a _cross note is link-connected to a project's sources.
This is the legacy inference predicate (a cross note is referenced by a
project note, or it links into a project source / project note). Explicit
cross: membership is now authoritative for INCLUSION; this predicate is
retained only to compute the suggested bucket — link-connected cross notes
the project has not yet claimed — so both composite builders score
suggestions identically.
Source code in zettelkasten/graph_io.py
load_review ¶
Load a single literature-review manifest from _reviews/
Returns {} for a missing file (no exception), mirroring
:func:load_project. _reviews/ lives beside _projects/ at
<repo>/.zettelkasten/.
A corrupt manifest (invalid YAML, non-UTF-8 bytes, unreadable inode) is
NOT silently swallowed: unlike the missing case it would mask real data
loss, so it is re-raised as a clear, catchable ValueError that the route
layer can map to a clean status instead of a raw 500 traceback.
Source code in zettelkasten/graph_io.py
list_reviews ¶
List all review manifests from _reviews/.
A single poison file (invalid YAML, non-UTF-8 bytes, unreadable inode) must
not 500 the whole list, so the offender is skipped and the rest keep
loading — mirroring :func:load_citations.