zettelkasten.opportunities¶
zettelkasten.opportunities ¶
Negative-space opportunity finder — the six PROPOSE-ONLY gap families.
Where :func:zettelkasten.claims.analyze_gaps surfaces weaknesses attached to
existing claims (evidential / dialectical / structural / temporal / coverage /
comprehensiveness), this module scans the NEGATIVE SPACE — the missing edges and
unmatched nodes across the knowledge graph and across the
memory↔zettelkasten boundary. It adds six new gap families as new
gap_type strings on the SAME engine:
asymmetry— a practice memory-node with no aligned canon claim, or a canon claim with no aligned practice node (a two-sided family read off the cross-store claim overlay).bridge— two dense clusters with (near-)zero connecting edges but high inter-cluster embedding similarity (a synthesis opportunity).crux— an unresolved central debate: a contested camp from the debate map, ranked by strength × centrality.orphaned_question— aquestionnote with no incoming answering edge (nosupports/responds-tobacklink).void— a sparse-but-surrounded region of embedding space (an unasked question / underexplored area amid density).transfer— a method/model note applied within one thematic cluster that structurally fits an adjacent cluster where it has not been tried.
Every detector is a PURE function of an already-built
:class:~zettelkasten.claims.ClaimIndex plus explicit inputs (clusters, vectors,
the debate map, the cross-store overlay) — it returns list[dict] of gaps
built via :func:zettelkasten.claims._gap, and NEVER writes to any store. Each
gap's action only DESCRIBES the suggested next step (the same propose-only
posture the rest of the claim engine keeps). Optional model/embedder dependencies
are injected as parameters (default None) so tests run deterministically
without a model.
This is a LEAF module: nothing in the package imports it at load time.
:func:zettelkasten.claims.analyze_gaps does a LAZY from zettelkasten import
opportunities inside its body only when one of the six families is requested,
so claims.py never imports synapse (which imports claims) at module
load and the circular import is avoided.
find_asymmetry ¶
find_asymmetry(index: ClaimIndex, *, overlay: 'dict[str, Any] | None', memory_source: Any, kc_by_key: 'dict[Key, Any] | None' = None, max_sal: float = 1.0, practice_types: 'tuple[str, ...] | None' = None, practice_salience: float = ASYMMETRY_PRACTICE_SALIENCE) -> 'list[dict[str, Any]]'
Two-sided cross-store asymmetry: unmatched practice ↔ unmatched canon.
Reads the claim-aligned cross-store overlay (memory --relation--> zk
edges). A practice memory-node is unmatched when its id never appears as an
overlay memory_id; a canon claim is unmatched when its (source, id)
never appears as an overlay (zk_source, zk_id) endpoint. Each gap's anchor
identifies the unmatched node and which side is missing.
Practice nodes are restricted to the overlay's own practice registers
(practice_types) so ordinary annotations/notes — which were never
connection candidates — do not flood the report. This is a TWO-SIDED family,
so it returns [] when EITHER cross-store side is unavailable: an
absent/empty overlay (no practice↔canon edges) OR a missing memory_source
(the practice side cannot be read). Never crashes.
Source code in zettelkasten/opportunities.py
310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 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 | |
find_bridges ¶
find_bridges(index: ClaimIndex, *, clusters: 'list[list[Key]]', vectors: 'dict[Key, list[float]]', centrality: 'dict[Key, float]', min_similarity: float = BRIDGE_MIN_SIMILARITY, max_cross_edges: int = BRIDGE_MAX_CROSS_EDGES, min_cluster_size: int = BRIDGE_MIN_CLUSTER_SIZE, hub_max_fanout: int = BRIDGE_HUB_MAX_FANOUT, hub_min_shared_hubs: int = BRIDGE_MIN_SHARED_HUBS) -> 'list[dict[str, Any]]'
Pairs of dense clusters with few connecting edges but similar centroids.
A synthesis opportunity: two thematically-close regions the corpus has never
linked. The anchor carries a representative from EACH cluster so the frontend
can draw a ghost edge. O(k²) over the (small) number of clusters. Returns
[] when fewer than two clusters exist.
"Unconnected" means unconnected DIRECTLY and INDIRECTLY: a pair linked only
through a shared excluded hub note (an evidence/organizational note that is
never clustered) is treated as connected — those clusters already share a
one-hop path, so they are not a genuine synthesis gap. Hub suppression tracks
real connectivity via hub_max_fanout (a focused hub suppresses on its own)
and hub_min_shared_hubs (a diffuse high-degree hub suppresses only when
corroborated), so a single mega-hub cannot wipe every bridge it touches yet
genuinely co-shared pairs are still suppressed (see _hub_connected_pairs).
Source code in zettelkasten/opportunities.py
find_crux ¶
find_crux(index: ClaimIndex, *, debate: 'dict[str, Any] | None', centrality: 'dict[Key, float]', importance_map: 'dict[str, float] | None' = None) -> 'list[dict[str, Any]]'
Unresolved central debates: contested camps ranked by strength × centrality.
Reads the camps from :func:zettelkasten.claims.debate_map and keeps only the
contested ones (a live, unresolved debate). Each camp is scored so a
strong, load-bearing debate is the crux worth resolving. The action suggests
resolving via a supersedes decision. Returns [] when there are no
contested camps.
Centrality is counted EXACTLY ONCE, as a single per-camp GLOBAL factor:
within_strength— the mean of the camp members'claim_strength(how well-evidenced the debate is), with NO centrality weighting, so a genuinely central debate is not attenuated by its own members' centrality.global_factor— the camp's most-central member over the global max centrality across ALL claims. A central debate keeps ~1.0; a globally-peripheral lone camp is scaled toward 0 so it cannot inflate to top severity even when its members are internally strong.
Salience is within_strength × global_factor. (A prior version ALSO weighted
each member's strength by a within-set–normalized centrality, so centrality
entered twice — a ~centrality² attenuation that under-scored a debate central
among the contested set but globally peripheral. Folding centrality into the
single global factor counts it once.) Salience stays in [0, 1]; the
div-by-zero and all-equal guards are preserved (an empty or all-zero centrality
map yields a 0 factor, never a crash).
Source code in zettelkasten/opportunities.py
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 504 505 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 | |
find_orphaned_questions ¶
find_orphaned_questions(index: ClaimIndex) -> 'list[dict[str, Any]]'
question notes with no incoming answering edge (supports / responds-to).
An open question nobody has answered. Salience rises with how connected the
question is (a well-situated but unanswered question matters more). A question
is skipped when it is no longer an open ask, in either of two ways: its status
is discarded (soft-deleted), or it carries an incoming supersedes edge
(belief-revised by a later note — the edge-based supersession the rest of the
engine records, since status="superseded" is never written). Every other
status — including the unanswered open / proposed / draft authoring
states — is surfaced.
Source code in zettelkasten/opportunities.py
find_voids ¶
find_voids(index: ClaimIndex, *, clusters: 'list[list[Key]]', vectors: 'dict[Key, list[float]]', centrality: 'dict[Key, float]', min_similarity: float = VOID_MIN_SIMILARITY, void_max_size: int = VOID_MAX_SIZE, dense_min_size: int = VOID_DENSE_MIN_SIZE, labeler: 'Callable[[list[Key]], str] | None' = None) -> 'list[dict[str, Any]]'
Sparse-but-surrounded regions: a tiny cluster hugging a dense one.
A small cluster (<= void_max_size) whose centroid sits close
(>= min_similarity) to a DENSE cluster (>= dense_min_size) is an
underexplored area amid density — an unasked question. Optionally named by an
injected labeler (else a deterministic label). Returns [] when there
is no dense cluster to sit beside.
Source code in zettelkasten/opportunities.py
find_transfers ¶
find_transfers(index: ClaimIndex, *, clusters: 'list[list[Key]]', vectors: 'dict[Key, list[float]]', centrality: 'dict[Key, float]', min_similarity: float = TRANSFER_MIN_SIMILARITY, method_types: 'tuple[str, ...] | None' = None) -> 'list[dict[str, Any]]'
A method/model note that structurally fits an untried adjacent cluster.
For each method/model note in a cluster A, find the most similar OTHER cluster
B (centroid sim >= min_similarity) to which the note has NO edge — the
method has not been tried there. The anchor carries the source method note and
the target cluster's representative. Returns [] when fewer than two
clusters exist.
Source code in zettelkasten/opportunities.py
detect_gaps ¶
detect_gaps(*, index: ClaimIndex, ctx: tuple, get_graph: 'Callable[[str], Any]', centrality: 'dict[Key, float]', importance_map: 'dict[str, float] | None', kc_by_key: 'dict[Key, Any] | None', max_sal: float, wanted: 'set[str] | None', localize: 'Callable[[str], str] | None' = None, namespace: 'Callable[[str], str] | None' = None, graphs_dir: Any = None, project: str = '', graph: str = '', embed_fn: 'Callable[[str, str], Any] | None' = None, overlay: 'dict[str, Any] | None' = None, memory_source: Any = None, debate: 'dict[str, Any] | None' = None) -> 'list[dict[str, Any]]'
Run the requested negative-space detectors and return their gaps (flat list).
Wires the production dependencies (vectors + clusters, the cross-store overlay,
the debate map) and dispatches to the pure detectors. Only families present in
wanted run (wanted=None runs them all). Each dependency is built
lazily, so requesting only orphaned_question never touches embeddings or
the overlay. overlay / memory_source / debate / embed_fn are
injectable for deterministic tests; unset, they are resolved from the stores.
graphs_dir + namespace describe the scan's scope: on a federated /
namespaced scan the (unscopeable) asymmetry loaders are skipped so a peer's
claims are never paired with the local overlay (see the asymmetry block).
Source code in zettelkasten/opportunities.py
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 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 | |