zettelkasten.remine¶
zettelkasten.remine ¶
Re-mining note->dimension classifier (synthesis-matrix Stage 1b CORE).
The deterministic matrix router (:func:zettelkasten.tables._route_column_members)
fills a cell from EXACT-TAG / spine-side membership only — a note that belongs
to a dimension but was never tagged for it is invisible to the grid. This module
is the agent-semantic tier that closes that gap: a NOTE-CENTRIC, BATCHED
classifier that reads the row's scoped notes and the schema's dimensions and
returns, per note, the dimension key(s) it belongs to plus a verbatim quote per
assignment. A note may land in MULTIPLE dimensions; notes that fit none are
SURFACED in a residual bucket rather than silently dropped.
It composes with the deterministic tier (never replaces it) via the
classify_fn seam that :func:zettelkasten.tables._route_members consumes:
build_classify_fn returns a per-column callable that runs ONE batched
classification per row (cached across that row's columns) and hands back the
assignments for the queried dimension. The member-level rules
(augment-never-replace, provenance/confidence, verbatim verification) live in
tables so this module stays a pure classifier.
Cost discipline, in order:
- Exact-tag short-circuit — notes already carrying a dimension's tag are already routed deterministically and are NEVER re-sent to the LLM.
- Embedding pre-filter — an optional :class:
~zettelkasten.embeddings.EmbeddingIndexgates which remaining notes reach the LLM: only notes whose vector is near a dimension's deterministic centroid (or a tagged seed) are sent. The threshold is LOOSE by default (favor recall — the LLM is the precision step) and tunable. - One batched call — the surviving candidate notes are classified against the
whole dimension set in a single LLM call, mirroring the
:func:
zettelkasten.tables._extract_row_cellscall/parse shape.
Single-schema scope today; the return shape carries a schema field per
assignment so a later note->{schema->dimensions} fan-out is additive.
RemineAssignment
dataclass
¶
One agent-inferred note->dimension assignment.
schema is carried (even though single-schema today) so the shape extends
cleanly toward note->{schema->dimensions} without breaking consumers.
Source code in zettelkasten/remine.py
RemineResult
dataclass
¶
The batched classification of one note set against one dimension set.
assignments maps note_id -> [RemineAssignment, ...] (a note may land in
several dimensions). residual lists the note ids that fit NO dimension and
were not already deterministically tagged — surfaced, never dropped.
considered is the candidate ids actually sent to the LLM (the rest were
pruned by the exact-tag short-circuit or the embedding pre-filter).
Source code in zettelkasten/remine.py
for_dimension ¶
The seam payload for one dimension: [{note_id, quote, confidence}].
Source code in zettelkasten/remine.py
expand_dimensions ¶
Build classifier dimensions from a registry schema's expanded spec.
Reuses :func:zettelkasten.extraction_schemas.expand_schema so the dimension
descriptions the classifier (and the embedding pre-filter) read are exactly
the schema's — keyed by the dimension tag, which is what a spine/schema_tag
column keys its dimension on. Returns [] for an unknown schema.
Source code in zettelkasten/remine.py
classify_notes ¶
classify_notes(notes: 'list[Any]', dimensions: 'list[dict[str, Any]]', *, extract_fn: 'Callable[[str, str], str] | None' = None, embed_index: Any = None, similarity_threshold: float = DEFAULT_SIMILARITY_THRESHOLD, schema: str = '', body_char_budget: int = 2000) -> RemineResult
Classify notes into dimensions in ONE batched LLM call.
notes is a list of (zg, note) scope pairs (or bare note objects).
dimensions are descriptors (key/tag/title/desc), e.g. from
:func:expand_dimensions. extract_fn(system, prompt) -> str is the LLM
seam (mirrors :func:zettelkasten.tables._extract_row_cells); inject a fake in
tests. embed_index (optional) gates LLM cost via the loose embedding
pre-filter.
Pipeline: exact-tag short-circuit (skip already-tagged notes per dimension) →
embedding pre-filter (per dimension) → one batched LLM call over the union of
surviving candidates → parse, gated so an assignment is kept only when the
note actually survived that dimension's gate. Notes assigned to no dimension
(and not deterministically tagged anywhere) are returned in residual.
Source code in zettelkasten/remine.py
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 | |
build_classify_fn ¶
build_classify_fn(dimensions: 'list[dict[str, Any]]', *, extract_fn: 'Callable[[str, str], str] | None' = None, embed_index: Any = None, similarity_threshold: float = DEFAULT_SIMILARITY_THRESHOLD, schema: str = '', residual_out: 'dict[str, list[str]] | None' = None) -> 'Callable[[Any, dict[str, Any]], list[dict[str, Any]]]'
A per-column classify_fn for :func:zettelkasten.tables._route_members.
The matrix router calls classify_fn(scope, column) once PER non-prompt
column of a row. Re-running the batched classifier per column would be
wasteful, so the returned closure runs :func:classify_notes ONCE per row
(keyed by the live scope object) and caches the result, then returns just the
assignments for the queried column's dimension (column["key"]).
residual_out (optional) is the sink for the residual bucket: each row's
unassigned note ids are recorded under residual_out[scope.id] so the
caller can SURFACE notes that fit no dimension (they are never dropped).
extract_fn defaults to the in-process dashboard agent; inject a fake in
tests. Production typically does::
from zettelkasten import remine
dims = remine.expand_dimensions("decision-profile")
classify_fn = remine.build_classify_fn(dims, embed_index=index)
rows = tables.gather_rows(get_graph, cols, ..., classify_fn=classify_fn)
Source code in zettelkasten/remine.py
induce_dimensions ¶
induce_dimensions(scope_notes: 'list[Any]', intent: str, *, llm_fn: 'Callable[[str, str], str] | None', embed_index: Any = None, max_cols: int = 8) -> list[dict[str, Any]]
Induce classifier dimensions (FACET columns) from a corpus + user intent.
The agent (llm_fn(system, prompt) -> str, the same call/parse seam as the
classifier/extract callables) reads a compact digest of scope_notes and the
user's intent ("make a matrix for X") and proposes a small set of facet
columns, each {tag, title, desc}. When embed_index is supplied the
candidate facets are grounded with embedding clustering
(:func:zettelkasten.embeddings.propose_clusters over get_vector); with
embed_index=None it is agent-only.
The result is normalized through the SAME
:func:_normalize_remine_dimensions registry dims pass through, so induced dims
are byte-compatible with registry dims and flow through
:func:_columns_from_dimensions unchanged. Facet keys/tags are slugs that are
DETERMINISTIC given identical facet TEXT — but the agent that emits the facets
is not stabilized, so a re-run may yield different facet text and therefore
different columns (a known limitation, analogous to the Stage 2 semantic row-id
follow-up). True-duplicate facets collapse; genuinely different facets that slug
to the same key are disambiguated rather than dropped (see
:func:_facets_to_dimensions). Returns [] when there is nothing to induce
(empty corpus, empty intent, or no llm_fn).
Source code in zettelkasten/remine.py
induce_subfacets ¶
induce_subfacets(cell_notes: 'list[Any]', parent_facet: dict[str, Any], *, llm_fn: 'Callable[[str, str], str] | None', embed_index: Any = None, min_clusters: int = 2, min_cluster_size: int = 2, min_cohesion: float = 0.55, max_children: int = 8, max_depth: int = 1) -> list[dict[str, Any]]
Induce child FACET columns under one emergent column from its cell notes.
cell_notes are the (zg, note) pairs (or bare notes) that the classifier
routed into parent_facet's column. They are sub-clustered by embedding
cohesion (:func:zettelkasten.embeddings.propose_clusters with
threshold=min_cohesion, max_k=max_children); the surviving clusters are
NAMED by the agent into child facets through the SAME call/parse/slug seam as
:func:induce_dimensions (llm_fn + :func:_build_induce_prompt +
:func:_parse_json_array + :func:_facets_to_dimensions), so child keys
are deterministic, disambiguated, and build_spine_skeleton-shaped.
Cohesion is the REQUIRED signal — with embed_index=None (no vectors) there
is no basis to claim the column splits, so this returns [] rather than
fabricating a subtree. A sufficient-clusters GATE keeps the column FLAT unless
at least min_clusters clusters of at least min_cluster_size members
survive; the unclustered bucket and sub-min clusters do NOT each become a
child. max_depth bounds recursion: each named child's own cluster is the
corpus for its depth pass (max_depth - 1), so a child carries its own
children only while depth remains. Returns [] when the gate fails, the
corpus is empty, llm_fn is missing, or max_depth <= 0.
Determinism: :func:zettelkasten.embeddings.propose_clusters is deterministic
and the text→slug mapping is deterministic given identical facet text, so
identical inputs yield identical child keys. As in Stage 3, the agent's
NAMING itself is not stabilized — a re-run can emit different child titles and
therefore different slugs (a known limitation).
Source code in zettelkasten/remine.py
909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 | |
build_scope_embed_index ¶
build_scope_embed_index(get_graph: Any, *, project: str = '', graph: str = '', graphs_dir: Any = None, localize: 'Callable[[str], str] | None' = None) -> _ScopeEmbeddingIndex
Build a LAZY scope-spanning embedding index for the Stage 4 depth pass.
The production PROPOSE entry points (the remine MCP tool and the dashboard
PROPOSE route) call this on the EMERGENT (intent) path ONLY and ONLY when
depth >= 1 so an induced column's cell notes can actually be sub-clustered
into children. It reuses each scope graph's own per-box
:class:~zettelkasten.embeddings.EmbeddingIndex (the existing mechanism); see
:class:_ScopeEmbeddingIndex for the laziness contract. project/graph
name the owner scope exactly as :func:propose_remine partitions it; pass the
same localize the route uses so ref-form graph names resolve.
Source code in zettelkasten/remine.py
read_prior_members ¶
The prior dimension-node membership of a PROMOTED spine, keyed by column.
For an org already materialized into a spine, each materializable column
carries its dimension node id (apex) and that node's synthesis graph
(graph); the node's outgoing spine-member edges name the notes routed
under it (dim --spine-member--> note@home_graph). This returns
{column_key: {member note id, ...}} — the prior map :func:reconcile_ids
matches induced facets against by MEMBER OVERLAP so a re-mine reuses the
durable column key/node id instead of minting a fresh slug. Member ids are bare
note ids, the SAME uid the rest of re-mining uses (cell note_id).
Returns {} for a not-yet-promoted org (lens only — no apex/graph), an
unknown org, or any unreadable spine graph → the caller then treats the re-mine
as GREENFIELD (every key minted in order, byte-identical to today).
Source code in zettelkasten/remine.py
propose_remine ¶
propose_remine(get_graph: Any, *, dimensions: 'list[dict[str, Any]] | None' = None, schema: str = '', columns: 'list[dict[str, Any]] | None' = None, project: str = '', graph: str = '', name: str = '', row_axis: 'dict[str, Any] | None' = None, graphs_dir: Any = None, localize: 'Callable[[str], str] | None' = None, extract_fn: 'Callable[[str, str], str] | None' = None, classify_fn: 'Callable[[Any, dict[str, Any]], list[dict[str, Any]]] | None' = None, embed_index: Any = None, similarity_threshold: float = DEFAULT_SIMILARITY_THRESHOLD, intent: str = '', induced_dimensions: 'list[dict[str, Any]] | None' = None, prior_members: 'dict[str, set[str]] | None' = None, max_cols: int = 8, depth: int = 0, title: str = '', table_id: str = 'remine-proposal') -> dict[str, Any]
PROPOSE phase: build a re-mined grid + residual bucket. NO writes.
Gathers one cell per dimension over the row-axis scope with the agent-semantic
classifier enabled, so each cell carries its deterministic members PLUS the
classifier's agent-inferred members (provenance='reviewer-inferred' with
confidence/verified/quote). The returned payload mirrors a
:func:zettelkasten.tables.build_matrix result so it renders through the
identical matrix UI, and additionally carries residual (a.k.a.
unassigned) — the in-scope notes that landed in NO dimension and were not
already deterministically tagged, surfaced rather than silently dropped.
Column resolution, most-fixed first:
- An explicit
dimensionslist (classifier descriptorskey/tag/title/desc) is used verbatim. - A registered
schemaexpands to its fixed dimensions via :func:expand_dimensions(the UNCHANGED default path). - With NEITHER, an
intent("make a matrix for X") and/or an explicitinduced_dimensionslist INDUCES the columns from the row corpus via :func:induce_dimensions(Stage 3 facet induction). Induced columns are flaggedinduced=Trueso the UI can show they were synthesized.
columns default to schema_tag columns derived from the dimensions. The
classifier is built from extract_fn (the LLM seam — production defaults to
the in-process agent; tests inject a fake) via :func:build_classify_fn, or a
fully-formed classify_fn can be injected directly. NOTHING is persisted or
attached — the caller must explicitly APPLY.
prior_members ({column_key: {member note id, ...}}, e.g. from
:func:read_prior_members) is the EXISTING spine's dimension-node membership.
On the induced path it lets each induced facet REUSE the prior column key when
its classified members overlap a prior dimension (via :func:reconcile_ids),
so a re-mine over an existing spine does not churn column keys / orphan
dimension nodes when the agent renames or reorders a facet. None/empty (no
prior spine) is GREENFIELD — every key is the freshly-minted slug, byte-
identical to the no-prior result.
depth (default 0 = OFF) opts into Stage 4 emergent DEPTH: with
depth >= 1 AND an EMERGENT (induced) column set AND an embed_index, each
induced column's classified cell notes are sub-clustered and, when cohesive,
named into child facets via :func:induce_subfacets and attached as that
column's children (recursing up to depth levels). The depth pass is
READ-ONLY (no new writes) and NEVER touches an enforced/registry column. With
depth == 0 the output is byte-identical to the Stage 3 result (no
children key on any column).
Source code in zettelkasten/remine.py
1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 | |
apply_remine ¶
apply_remine(owner_type: str, owner_name: str, org_id: str, *, grid: 'dict[str, Any] | list[dict[str, Any]]', get_graph: Any, graphs_dir: Any = None, tag_stamp: bool = False, attach_relation: 'str | None' = None, row_relation: 'str | None' = None, link_relation: 'str | None' = None) -> dict[str, Any]
APPLY phase: materialize an approved proposed grid via the attach seam.
Reuses :func:zettelkasten.organizations.promote_organization with
proposed_grid= — the ONLY edge-writer — so the approved grid promotes
through the identical scaffold/attach/reconcile machinery (membership is
reconciled, never add-only, so a re-run prunes members no longer classified
and adds new ones; idempotent). grid is the PROPOSE payload (its rows
are used) or a bare rows list. tag_stamp (default OFF) is the one opt-in
that mutates base notes. Returns an apply summary the routes/tool echo.
APPLY-GATE: induced (intent-proposed) grids are preview-only — their columns
are synthesized from the corpus, not the org's fixed schema, so promoting one
onto an org with a different schema is a corruption vector. The guard lives
HERE, where the ROW cells are consumed, so it cannot be bypassed by stripping
grid["columns"] (a flag-only check upstream can be): a grid carrying
induced columns is refused, AND a grid whose row cells carry any key absent
from the target org's FIXED schema is refused (this catches a columns-stripped
bare-rows induced grid). A legitimate registry-schema grid, whose cell keys are
exactly the org's column keys, applies unchanged.
Source code in zettelkasten/remine.py
1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 | |
resolve_source_scope ¶
The graphs whose notes are this spine's classifier scope (source→persona).
Resolution, most-precise first:
- The spine apex's per-source rollup edges (:func:
_apex_source_graphs) — the persona's OWN source corpora, even when the project hosts several personas over different subsets. - The owner scope: a graph-owned org classifies its own graph; a
project-owned org classifies the project's registered sources
(
framing.frame_search_sources), minus the spine graph itself and the_system graphs (e.g._cross).
Never includes the spine graph itself (its nodes are structure, not evidence).
Source code in zettelkasten/remine.py
backfill_spine ¶
backfill_spine(owner_type: str, owner_name: str, org_id: str, *, get_graph: Any, classify_fn: 'Callable[[Any, dict[str, Any]], list[dict[str, Any]]]', graphs_dir: Any = None, source_graphs: 'list[str] | None' = None, apply: bool = True, tag_stamp: bool = False, row_id: str = '', row_label: str = '') -> dict[str, Any]
Backfill evidence members onto a persona spine's FIXED dimension nodes.
The degenerate re-mine: rows + columns are already fixed (the persona and its
reverse-engineered schema), so this runs classify_fn over the persona's
OWN source-note scope (:func:resolve_source_scope) and attaches the results
onto the EXISTING dimension nodes via the promote-attach path
(promote_organization(..., proposed_grid=...)) — additive (a node's ported
synthesized body is preserved), reconcile-based (a re-run prunes/adds), and
idempotent.
classify_fn(scope, column) -> [{note_id, quote, confidence}] is the
per-column seam (:func:build_classify_fn in production; a canned callable in
tests). The classified note ids are mapped back to their HOME graph from the
scope so the attach edge points at the right base note. With apply=False
the proposed grid is returned WITHOUT writing (the propose-first preview).
Source code in zettelkasten/remine.py
1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 1923 1924 1925 1926 1927 | |