zettelkasten.syllabus¶
zettelkasten.syllabus ¶
Reading-syllabus scoring: a two-axis, debiased notion of paper importance.
This module is the deterministic scoring engine behind the Workshop / Papers view.
Like :mod:zettelkasten.framing, it is a pure, cacheable, unit-testable layer
parametrized by a get_graph callable so the MCP server and the dashboard
backend (separate graph caches) can share it. There is no LLM here — every
score is a reproducible function of the corpus.
Importance is defined along two independent axes that are exposed separately so the Papers view can sort/color by either alone:
-
influence — cohort-normalized standing of
cited_by_count. It is the percentile of a work's raw count within its cohort (a publication-year window × theme), computed over the corpus. The raw count is never used directly: this debiases for paper age, field size, and survey citation inflation, so a heavily-cited paper in a citation-rich cohort does not automatically outrank a modestly-cited paper that leads a sparse cohort. -
salience — how load-bearing a work is inside our own graph: a 0–1 blend of (1) the weighted in-degree of incoming
supports/replicates/extends/citesedges from distinct sources, (2) graph centrality (PageRank over the note/citation graph), and (3) claim-coreness (how many key claims a work is the core paper for). The claim layer does not exist yet, so claim-coreness defaults to 0 and is dropped from the blend until it appears — a clean seam that composes later without re-tuning today's scores.
Derived from those:
- importance — a configurable blend of influence + salience (default 50/50).
- anchor_score — a separate facet (is_review / out-degree / co-citation): good entry points into a literature, distinct from importance.
- read_priority —
importance × unread(coverage.human) × claim-coverage: what to read next. Drops to 0 once a work is fully read by a human.
The pure primitives operate on a list of :class:Work records, so they are
trivial to unit-test against a synthetic corpus. :func:build_corpus assembles
those records from the live graph (citations + sources + note edges) and is the
seam the later M0 waves (interestingness, trend layer, situate()) hook into.
ImportanceWeights
dataclass
¶
SalienceWeights
dataclass
¶
Blend of salience's sub-signals. Signals absent across the whole corpus
are dropped and the remaining weights renormalized (see :func:salience).
Source code in zettelkasten/syllabus.py
AnchorWeights
dataclass
¶
Blend of the anchor facet's sub-signals (entry-point quality).
Source code in zettelkasten/syllabus.py
Work
dataclass
¶
A single scorable paper-like entity (a citation or a promoted source).
Graph-derived signals (weighted_in_degree, centrality,
out_degree, co_citation) and the claim hooks default to 0 / neutral
so a bare Work is well-defined and the scorers compose even before
:func:build_corpus has populated them.
Source code in zettelkasten/syllabus.py
TrendMetrics
dataclass
¶
Per-paper temporal signals, all reconstructed as of reference_year.
Frozen and self-contained so later waves can pass it around as a clean value.
velocity/acceleration are in raw citations-per-year (and per-year²);
citation_recency/publication_recency are 0–1 shares; trajectory is
one of :data:TRAJECTORY_CLASSES.
Source code in zettelkasten/syllabus.py
InterestingnessWeights
dataclass
¶
Blend weights for the interestingness sub-signals (used by the blend term; the max term is weight-free). Absent signals contribute 0 and are simply out-weighed rather than dropped, since each is already a 0–1 normalized quantity.
Source code in zettelkasten/syllabus.py
Interestingness
dataclass
¶
Per-paper interestingness, with the dominant firing sub-signal recorded.
score is 0–1. dominant_signal is the name of the strongest firing
sub-signal (one of the five fields below), or None when nothing fired
(or the work was floored out by the relevance guard) — which the rationale
layer reads to decide between an explanation and a neutral return.
Source code in zettelkasten/syllabus.py
Situation
dataclass
¶
Per-paper context labels. Any label may be None when nothing applies.
temporal ∈ {seminal, recent, origin-of-claim, None}; position ∈
{foundational, extension, superseded, replication, review, bridge,
frontier-rising, None}; influence is the wave-1 cohort percentile (0–1)
with influence_label its tier and sleeper flagging a core-but-undercited
work.
Source code in zettelkasten/syllabus.py
Theme
dataclass
¶
A single theme: a labeled group of member works plus its weight.
work_ids are the in-corpus members (sorted for stable output). weight
is len(work_ids) × Σ member cited_by_count — size times external citation
mass. source records which priority tier produced it (landscape /
cluster / cold-start).
Source code in zettelkasten/syllabus.py
ThemeModel
dataclass
¶
The corpus's theme partition.
themes is ordered by descending weight then theme_id (a stable,
deterministic order). assignment maps each member work to its primary
theme — the highest-weight theme it belongs to — for display; the per-theme
work_ids remain the authoritative (possibly overlapping) membership that
:func:set_cover consumes. source is the priority tier that produced the
partition.
Source code in zettelkasten/syllabus.py
CoverSelection
dataclass
¶
One paper chosen for the reading list, with the units it covers.
theme_ids are the themes it covers (theme-coverage mode); claim_ids
are the key-claim ids it is the CORE paper for (claim-coverage mode). Exactly
one is populated depending on the cover universe (the other stays an empty
tuple), so existing theme-mode consumers reading theme_ids are unaffected.
acquisition marks a coverer whose work is UNOWNED (source_graph is
None): it covers its unit only by being ACQUIRED, not by being read. It is
set at the :func:set_cover boundary so an owned-only read-hook consumer can
filter it out deterministically (an unowned work must never appear in
owned-only reading recommendations) without re-deriving ownership from the
work list — the invariant lives in set_cover, not its callers.
Source code in zettelkasten/syllabus.py
SetCover
dataclass
¶
A suggested reading list.
selected is the flat list of chosen papers in selection order;
by_theme groups the selected work ids by the themes they cover; gaps
are units left with unmet residual need (budget exhausted, or no selectable
coverer at all — every member out of budget, already fully read, or an
unresolved claim core); pre_covered are units that needed nothing because
a member was already fully read.
by_theme/by_claim group the picks by the THEME and CLAIM key spaces
respectively; claim_ids is the claim-space subset of the covered units.
gaps UNIONS both key spaces (theme ids ∪ "<graph>::<id>" claim uids) —
consult universe ("theme" / "claim" / "composite") to know
which spaces are populated. The claim fields default empty so theme-only
callers and persisted theme covers are byte-for-byte unchanged.
Source code in zettelkasten/syllabus.py
influence ¶
influence(works: list[Work], *, cohort_window: int = DEFAULT_COHORT_WINDOW) -> dict[str, float]
Cohort-normalized influence in 0–1: the percentile of cited_by_count
within each work's (year-window × theme) cohort.
Uses the mean/Hazen percentile rank (#below + 0.5·#equal) / cohort_size,
which gives two works that hold the same standing in their respective
cohorts the same score regardless of their raw counts, and never uses the
raw count directly. A singleton cohort scores 0.5 (neutral).
Only works with a KNOWN citation count (cited_by_count_known) participate:
cohorts are formed from them and the returned dict has an entry only for
them. A work whose citation count was never enriched (a DOI-less paper stuck
at 0) is intentionally absent — callers treat a missing id as "influence
unknown" rather than a real low percentile, so it can't depress the cohort it
happens to share a year/theme with, nor read as a confidently low score.
Source code in zettelkasten/syllabus.py
salience ¶
salience(works: list[Work], *, weights: SalienceWeights | None = None) -> dict[str, float]
In-graph salience in 0–1: blend of distinct-source weighted in-degree, PageRank centrality, and claim-coreness.
Each sub-signal is max-normalized so it spans 0–1, then blended by
:func:_blend (which drops absent signals). The result is monotonic in
weighted_in_degree while only that signal is present, which is the
intended "load-bearing-ness" behavior.
Source code in zettelkasten/syllabus.py
importance ¶
importance(works: list[Work], *, weights: ImportanceWeights | None = None, influence_scores: dict[str, float] | None = None, salience_scores: dict[str, float] | None = None, cohort_window: int = DEFAULT_COHORT_WINDOW) -> dict[str, float]
Configurable blend of influence + salience in 0–1 (default 50/50).
Blended through :func:_blend, so an axis that is entirely absent across the
corpus (e.g. salience on a graph with no note edges) is dropped and the
surviving weight renormalized — matching salience/anchor_score. This
preserves the seam invariant: a no-edge corpus yields importance ==
influence (not a halved blend), so today's scores compose without re-tuning
when the missing axis lights up later.
Precomputed axis scores can be passed in to avoid recomputation when a caller already has them (the Papers view needs all axes at once).
Source code in zettelkasten/syllabus.py
anchor_score ¶
anchor_score(works: list[Work], *, weights: AnchorWeights | None = None) -> dict[str, float]
Anchor facet in 0–1: how good a entry point into the literature a work is.
A separate facet from importance — reviews, well-connected hubs, and frequently co-cited works are good places to start reading even when they are not the most important results. Blended (and absent-signal-dropped) the same way as salience.
Source code in zettelkasten/syllabus.py
read_priority ¶
read_priority(works: list[Work], *, importance_scores: dict[str, float] | None = None, cohort_window: int = DEFAULT_COHORT_WINDOW) -> dict[str, float]
What to read next, in 0–1: importance × unread(coverage.human) ×
claim-coverage.
A fully human-read work (coverage.human == "full") has an unread factor of
0, so its read_priority is 0 regardless of importance.
Source code in zettelkasten/syllabus.py
score_corpus ¶
score_corpus(works: list[Work], *, importance_weights: ImportanceWeights | None = None, salience_weights: SalienceWeights | None = None, cohort_window: int = DEFAULT_COHORT_WINDOW) -> list[dict[str, Any]]
Score every work along all axes and return rows sorted by read_priority.
This is the composable output the Papers view consumes — each axis is present
independently (influence and salience separately, plus the derived
importance, anchor_score and read_priority) so the UI can sort or
color by any of them.
Source code in zettelkasten/syllabus.py
build_corpus ¶
build_corpus(get_graph: GetGraph, *, project: str = '', graphs_dir: 'Path | None' = None, namespace: Callable[[str], str] | None = None, citation_scope: 'set[str] | None' = None) -> tuple[list[Work], list[str]]
Assemble the scorable corpus from the live graph.
Returns (works, search_sources). Works are built from the citation store
and from the note-graph sources (deduplicated against citations by canonical
key, with the source's coverage merged onto the matching citation). Graph
signals are derived here:
weighted_in_degree— for each work, the summed :data:SALIENCE_RELATIONSweight of incoming edges, counting each source graph at most once (its strongest edge), i.e. distinct-source in-degree.centrality— PageRank over the source→target edge graph.out_degree— a source's count of outgoing salience edges.
Claim signals and inter-citation co-citation are left at their defaults (later M0 waves). Pure: no LLM, no network.
Dedup & merge. Records that resolve to the same work by
:func:canonical_citation_key are collapsed onto a single :class:Work,
whether the duplicates are citation-vs-citation (two files sharing a DOI) or
source-vs-citation (a promoted source that is also a citation). On merge the
graph signals are folded together — distinct-source weighted_in_degree
(max per source across both id forms, then summed), centrality (summed,
as each id-node accrued its own PageRank mass), cited_by_count (max) —
and counts_by_year/coverage are filled from whichever record carries
them, so no endorsement edge or PageRank mass is orphaned by the dedup. A
degenerate canonical key (the constant "meta:" for metadata-poor works,
or a placeholder DOI like doi:n/a) does not discriminate works, so such
records are never deduped — each keeps its storage id as identity (see
:func:_is_dedupable_key), keeping distinct works distinct.
Project scope. Sources and edges are scoped to project via
:func:frame_search_sources. Citations are scoped too: when project is
given, only citations referenced by the in-project sources enter the
corpus, so cohort percentiles are computed over the project's own pool. A
citation is "referenced" if any in-project note links to it — by any graph
(_citations, the default graph, _cross), derived from the very same
link walk that collects salience, so a citation whose in-degree is counted is
never then dropped by the scope filter. When project is empty the full
global citation pool is used (there is no per-citation project tag — the
association is derived from note links).
Explicit citation scope. citation_scope (when not None) restricts
the citation pool to exactly that id set, overriding the project-derived
scoping. The single-source graph scope (:func:papers._graph_scoped_corpus)
uses it to avoid materializing the entire global citation pool: it passes a
dedup-group-closed set of the citations that one graph engages with, so the
surviving Works — and every canonical-key merge — are identical to the
full-pool corpus, only without building the unreferenced citations.
Source code in zettelkasten/syllabus.py
594 595 596 597 598 599 600 601 602 603 604 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 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 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 | |
score_syllabus ¶
score_syllabus(get_graph: GetGraph, *, project: str = '', graphs_dir: 'Path | None' = None, namespace: Callable[[str], str] | None = None, importance_weights: ImportanceWeights | None = None, salience_weights: SalienceWeights | None = None, cohort_window: int = DEFAULT_COHORT_WINDOW) -> dict[str, Any]
End-to-end: build the corpus from the graph and score every work.
The top-level seam the dashboard/MCP call. Returns a result dict (callers serialize as needed) with per-work rows and a small summary.
Source code in zettelkasten/syllabus.py
trend_metrics ¶
trend_metrics(work: Work, reference_year: int, *, window: int = TREND_WINDOW) -> TrendMetrics
Per-paper trend metrics for work as of reference_year.
The clean, importable accessor for velocity / acceleration / citation-recency
/ trajectory that wave 3 consumes. counts_by_year is truncated to
<= reference_year first, so this is the literal point-in-time view.
Source code in zettelkasten/syllabus.py
corpus_reference_year ¶
corpus_reference_year(works: list[Work], as_of: int | None = None, *, now_year: int | None = None) -> int
Resolve the reference year T for a corpus.
T is resolved one of two ways, kept strictly separate so the point-in-time replay stays pure while the live default is the only path that ever reads the wall clock:
- Point-in-time replay (
as_ofgiven): T isint(as_of)exactly — a pure, deterministic function of the argument, with no wall-clock access. This is what makes anas_of=Tresult reproducible regardless of when it is computed. - Live default (
as_of is None): T is today, a wall-clock value. A caller (notably a test) injects a deterministic "now" vianow_year; absent that, T falls back todatetime.now().year.
T is deliberately NOT inferred from the corpus's observed years. Deriving it
from data — by bare max, range-clamp, or even multi-work corroboration — let
a single corrupt future-dated record (a 2040 typo, a 9999 sentinel)
drag T into the future, all-zeroing every real paper's recent window and
flipping a thriving field to "all dormant"; and corroboration conversely
mislabeled a legitimately-fresh sole-holder-of-the-newest-year paper as
dormant on clean data. Pinning T to the wall clock removes the whole class of
failures: an outlier no longer contaminates its neighbors because T is not a
function of it — the outlier merely falls beyond T and is handled per-paper
by the existing future-publication guard (publication_recency 0, never
"emerging") and the counts_by_year truncation to <= T.
works is accepted for call-site symmetry with the rest of the trends
layer (and forward compatibility) but is intentionally unused: nothing about
T is read from the corpus anymore.
Source code in zettelkasten/syllabus.py
corpus_trends ¶
corpus_trends(works: list[Work], *, as_of: int | None = None, window: int = TREND_WINDOW, now_year: int | None = None) -> dict[str, TrendMetrics]
Per-work :class:TrendMetrics across the corpus, keyed by work id.
Honors the point-in-time contract: works published after as_of are
dropped and each surviving work's history is truncated to <= as_of. With
as_of omitted the live-default reference year is used (now_year if
given, else the wall-clock year — see :func:corpus_reference_year).
Source code in zettelkasten/syllabus.py
field_direction ¶
field_direction(works: list[Work], grouping: dict[str, str] | None = None, *, as_of: int | None = None, window: int = TREND_WINDOW, frontier_limit: int = 10, emerging_limit: int = 5, now_year: int | None = None) -> dict[str, Any]
Aggregate per-paper trajectories into a "field direction" roll-up.
Groups works (default: by :attr:Work.theme, the membership the corpus
already carries — pass an explicit grouping of work_id → label to roll
up by claims/concepts/clusters instead, so this composes with later layers)
and summarizes, per group, whether it is accelerating, cooling, or
stable. The top-level summary surfaces the accelerating and cooling
themes, the emerging groups (fastest-growing recent citation share), and the
current frontier (recent, high-velocity works).
Point-in-time aware: with as_of=T set, works published after T are
excluded and each history truncated to <= T (see module notes on the
honest limitations of this replay). With as_of omitted the live-default
reference year is used (now_year if given, else the wall-clock year).
Source code in zettelkasten/syllabus.py
1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 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 | |
trend_summary ¶
trend_summary(get_graph: GetGraph, *, project: str = '', graphs_dir: 'Path | None' = None, namespace: Callable[[str], str] | None = None, as_of: int | None = None, window: int = TREND_WINDOW, now_year: int | None = None) -> dict[str, Any]
End-to-end trends seam: build the corpus from the graph and roll it up.
Mirrors :func:score_syllabus (and reuses :func:build_corpus, never
duplicating corpus assembly) so the dashboard/MCP can request the temporal
view the same way they request scores. Pass as_of to replay the field as
of an earlier year T; pass now_year to pin the live-default reference
year deterministically (otherwise the wall-clock year is used).
Source code in zettelkasten/syllabus.py
interestingness ¶
interestingness(works: list[Work], *, embeddings: dict[str, Sequence[float]] | None = None, clusters: dict[str, str] | None = None, relation_edges: Iterable[tuple[str, str, str]] | None = None, trends: dict[str, TrendMetrics] | None = None, influence_scores: dict[str, float] | None = None, salience_scores: dict[str, float] | None = None, weights: InterestingnessWeights | None = None, read_state_aware: bool = False, cohort_window: int = DEFAULT_COHORT_WINDOW) -> dict[str, Interestingness]
Composite interestingness per work, decorrelated from salience.
The five sub-signals each measure something salience cannot:
- surprise — embedding distance to the work's cluster centroid.
- brokerage — a gated indicator that the work bridges ≥2 otherwise-separate
clusters. Gated to {0,1} rather than a graded cluster count so it cannot
rank-track citation volume (its neighbor set includes
citesedges, which also drive salience), keeping it decorrelated from salience. - dissent — how contested a claim is: the count/strength of incoming
contradicts/qualifies edges challenging it (and, in
read_state_awaremode, boosted against claims already fully read). Independent of the challenged claim's citation weight, so it is not a salience proxy. - citation_anomaly — a gated "sleeper" indicator (a structurally-core
and cohort-under-cited work in a real cohort, NOT a monotone function of
raw salience) OR a positive citation acceleration surge from the wave-2
:class:
TrendMetrics, whichever is larger. - novelty — embedding distance to the GLOBAL corpus centroid.
Each sub-signal is max-normalized to 0–1, then combined as
MAX_CONTRIB·max(signal) + (1-MAX_CONTRIB)·weighted_blend and gated by a
relevance floor (:func:_is_grounded) so disconnected/off-topic notes cannot
score high. The dominant firing sub-signal is recorded on each result.
Pure: embeddings/clusters/edges/trends are all supplied by the caller (the
dashboard owns the embedding + clustering machinery); absent inputs make the
corresponding signals 0 rather than raising. Wave-1 influence/salience
and wave-2 TrendMetrics are reused, never re-derived.
Source code in zettelkasten/syllabus.py
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 1928 1929 1930 1931 1932 1933 1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 | |
situate ¶
situate(works: list[Work], *, relation_edges: Iterable[tuple[str, str, str]] | None = None, clusters: dict[str, str] | None = None, trends: dict[str, TrendMetrics] | None = None, influence_scores: dict[str, float] | None = None, salience_scores: dict[str, float] | None = None, as_of: int | None = None, window: int = TREND_WINDOW, now_year: int | None = None, cohort_window: int = DEFAULT_COHORT_WINDOW) -> dict[str, Situation]
Per-paper context labels (temporal / literature position / influence).
A shared deterministic helper the rationale layer and the Papers view both
read. Reuses wave-1 :func:influence/:func:salience and wave-2
:func:corpus_trends (for trajectory/velocity) rather than re-deriving them;
pass precomputed scores/trends to avoid recomputation. relation_edges
((source, relation, target) triples) supply citation topology, and
clusters the bridge detection — both optional and defaulted the same way
as :func:interestingness.
Label derivation (each axis independent; within an axis the first matching rule in priority order wins, so labels are mutually exclusive):
- temporal — origin-of-claim (claim-core, the claim-layer hook) > recent (published within the trend window) > seminal (older and high cohort influence).
- position — review > superseded (target of a
supersedesedge) > frontier-rising (rising/emerging trajectory) > foundational (high influence with others building on it) > replication > extension > bridge. - influence — the wave-1 cohort percentile, tiered into high/medium/low,
with
sleeperset when a work is core (high salience) yet under-cited (low influence).
Source code in zettelkasten/syllabus.py
1962 1963 1964 1965 1966 1967 1968 1969 1970 1971 1972 1973 1974 1975 1976 1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047 2048 2049 2050 2051 2052 2053 2054 2055 2056 2057 2058 2059 2060 2061 2062 2063 2064 2065 2066 2067 2068 2069 2070 2071 2072 2073 2074 2075 2076 2077 2078 2079 2080 2081 2082 2083 2084 2085 2086 2087 2088 | |
why_read ¶
why_read(work: Work, *, salience: float = 0.0, importance: float = 0.0, anchor: float = 0.0, situation: Situation | None = None, contested: bool = False, threshold: float = _READ_REASON_THRESHOLD) -> str | None
Render a one-sentence "why read this" rationale from selection signals.
Picks the 1–2 STRONGEST firing reasons among core-paper / multi-claim /
anchor / salience / contested / foundational / importance. The continuous
signals (anchor, salience, importance) fire only above threshold; the
categorical ones (core/multi-claim from Work.core_claim_count, contested,
foundational from the work's :class:Situation) fire on their own. Returns
None when nothing fires, so a caller can omit the rationale entirely.
Source code in zettelkasten/syllabus.py
why_interesting ¶
why_interesting(interest: Interestingness | None) -> str | None
Render a one-sentence rationale from the dominant interestingness signal.
Returns None (neutral — nothing remarkable fired) when the score is ~0 or
no sub-signal dominated, otherwise a sentence keyed off the dominant signal.
Source code in zettelkasten/syllabus.py
theme_model ¶
theme_model(works: list[Work], *, clusters: dict[str, str] | None = None, relation_edges: Iterable[tuple[str, str, str]] | None = None, landscape_hubs: dict[str, Any] | None = None, anchor_scores: dict[str, float] | None = None, cold_start_limit: int = DEFAULT_COLD_START_LIMIT, merge_remainder: bool = False) -> ThemeModel
Group works into weighted themes by a strict source priority.
Theme source (first non-empty tier wins):
- (a) landscape hubs — when
landscape_hubs(concept hubs taggedlandscapein the graph) yield at least one in-corpus member. Each hub becomes a theme; membership is the hub's explicitwork_idsand/or the works itsurveys/citesinrelation_edges. - (b) embedding clusters — else, when
clusters(work_id → label, the convention :func:interestingness/:func:situateuse) is supplied. Works are grouped by :func:_resolve_clusters, so works absent from the mapping fall back to their :attr:Work.theme(or(untyped)). - (c) cold start — else, the top works by
anchor_score+ most-cited become singleton seed themes (up tocold_start_limit).
When merge_remainder is set AND landscape hubs win tier (a), the works
that no hub covers are NOT abandoned: they are themed by the cluster/cold-start
tier (b/c) and those themes are appended alongside the landscape themes. The
resulting source is "hybrid" when both tiers contribute. This keeps a
freshly-minted landscape hub (e.g. a promoted review section) from collapsing
the whole corpus into landscape-only — without it, the first hub would drop
every cluster/cold-start theme and orphan their claims. Default False
preserves the strict single-tier partition for non-review callers.
Each theme's weight is size × external citation mass = member count times
the summed OpenAlex cited_by_count of its in-corpus members (the pull the
theme exerts on the outside literature). Themes are returned sorted by
descending weight then theme_id; ties everywhere break on id, so the
result is fully deterministic. Members not present in works are dropped,
and a theme with no in-corpus members is omitted (it could never be covered).
Pure: no graph access beyond the supplied relation_edges, no LLM, no
network, no wall clock.
Source code in zettelkasten/syllabus.py
2349 2350 2351 2352 2353 2354 2355 2356 2357 2358 2359 2360 2361 2362 2363 2364 2365 2366 2367 2368 2369 2370 2371 2372 2373 2374 2375 2376 2377 2378 2379 2380 2381 2382 2383 2384 2385 2386 2387 2388 2389 2390 2391 2392 2393 2394 2395 2396 2397 2398 2399 2400 2401 2402 2403 2404 2405 2406 2407 2408 2409 2410 2411 2412 2413 2414 2415 2416 2417 2418 2419 2420 2421 2422 2423 2424 2425 2426 2427 2428 2429 2430 2431 2432 2433 2434 2435 2436 2437 2438 2439 2440 2441 2442 2443 2444 2445 2446 2447 2448 2449 2450 2451 2452 2453 2454 2455 2456 2457 2458 2459 2460 2461 2462 2463 2464 2465 2466 2467 2468 2469 2470 | |
set_cover ¶
set_cover(works: list[Work], themes: 'ThemeModel | Sequence[Theme]', *, budget: int = DEFAULT_COVER_BUDGET, importance_scores: dict[str, float] | None = None, read_priority_scores: dict[str, float] | None = None, relation_edges: Iterable[tuple[str, str, str]] | None = None, embeddings: dict[str, Sequence[float]] | None = None, redundancy_threshold: float = DEFAULT_REDUNDANCY_THRESHOLD, cohort_window: int = DEFAULT_COHORT_WINDOW, claim_cover: dict[str, str | None] | None = None) -> SetCover
Greedy, redundancy-penalized set cover over themes AND/OR key claims.
Builds a minimal high-value suggested reading list. The coverage universe is COMPOSITE: theme units and key-claim units compete in ONE greedy loop within ONE budget.
- themes — each theme is a unit covered by any of its member works.
- key claims — when
claim_cover(claim_uid → core work id) is supplied, each KEY CLAIM is a unit covered by its single CORE paper. The core work id may be an OWNED source OR an UNOWNED citation: an unowned core is an acquisition candidate — acquiring it would cover the claim — and it is admitted as a coverer just like an owned one. Only aNonecore work id (a claim with no candidate paper at all) has no selectable coverer and is a PERMANENT ACQUISITION GAP. Owned coverers are preferred over unowned ones on an exact tie (an unowned candidate never DISPLACES an equivalent owned coverer).
universe reports which spaces are live: "theme" when there are no
claim units (then EVERY output is byte-identical to the original theme-only
cover), "composite" when both theme and claim units exist, or "claim"
when only claim units exist. Theme units are NEVER abandoned when claims are
present — both are covered together.
The greedy machinery is identical for both universes — only the unit→coverer mapping and the result labelling differ:
- Pre-cover. A unit with a member the user has already fully read
(
coverage_human == "full") needs nothing — its residual need drops to 0 and it is reported inpre_covered(never a gap, never selected for). For a key claim, the only member is its core paper, so a claim is pre-covered iff its core paper is fully read. - Greedy marginal gain. While under
budgetand units remain uncovered, pick the unread candidate that covers the most still-uncovered units, breaking ties by value then id. Value isread_priority_scoresif supplied, elseimportance_scores, else a freshly computed :func:importance(what to read next / how important). - Redundancy (coverage-first penalty). A candidate that is a
near-duplicate of an already-selected paper (see :func:
_is_redundant) is deferred: a non-redundant coverer is always preferred when one exists. But redundancy is a penalty, NOT an absolute veto — if the only remaining coverers of an uncovered unit are near-duplicates, the best such coverer is still selected (coverage wins) rather than abandoning the unit. A contradicts/qualifies edge exempts a pair from redundancy entirely, so a claim and its rebuttal are both directly selectable. - Stop at the budget or when no candidate (redundant or not) yields positive marginal gain — i.e. every remaining uncovered unit has no selectable coverer left.
redundancy_threshold is an embedding cosine distance in [0, 2]
(0 = identical, 2 = opposite; smaller = stricter deduplication). It is
clamped into that range, so a similarity-like or out-of-range value cannot
collapse the corpus.
Returns the flat selection (in order), the selection grouped by_theme (or
by_claim in claim mode), the uncovered gaps, and the pre_covered
units. Pure and deterministic (stable tie-breaks; no RNG, no wall clock).
Source code in zettelkasten/syllabus.py
2524 2525 2526 2527 2528 2529 2530 2531 2532 2533 2534 2535 2536 2537 2538 2539 2540 2541 2542 2543 2544 2545 2546 2547 2548 2549 2550 2551 2552 2553 2554 2555 2556 2557 2558 2559 2560 2561 2562 2563 2564 2565 2566 2567 2568 2569 2570 2571 2572 2573 2574 2575 2576 2577 2578 2579 2580 2581 2582 2583 2584 2585 2586 2587 2588 2589 2590 2591 2592 2593 2594 2595 2596 2597 2598 2599 2600 2601 2602 2603 2604 2605 2606 2607 2608 2609 2610 2611 2612 2613 2614 2615 2616 2617 2618 2619 2620 2621 2622 2623 2624 2625 2626 2627 2628 2629 2630 2631 2632 2633 2634 2635 2636 2637 2638 2639 2640 2641 2642 2643 2644 2645 2646 2647 2648 2649 2650 2651 2652 2653 2654 2655 2656 2657 2658 2659 2660 2661 2662 2663 2664 2665 2666 2667 2668 2669 2670 2671 2672 2673 2674 2675 2676 2677 2678 2679 2680 2681 2682 2683 2684 2685 2686 2687 2688 2689 2690 2691 2692 2693 2694 2695 2696 2697 2698 2699 2700 2701 2702 2703 2704 2705 2706 2707 2708 2709 2710 2711 2712 2713 2714 2715 2716 2717 2718 2719 2720 2721 2722 2723 2724 2725 2726 2727 2728 2729 2730 2731 2732 2733 2734 2735 2736 2737 2738 2739 2740 2741 2742 2743 2744 2745 2746 2747 2748 2749 2750 2751 2752 2753 2754 2755 2756 2757 2758 2759 2760 2761 2762 | |