Step 5 — Link observations to hypothesis-clusters
Output = the evidence layer, built by one child per hypothesis-cluster (orchestrated by step 0), then a final consolidator. You are one cluster-child: for every observation that discriminates between your cluster’s members, mint one evidence-link (E-N) edge (observation → cluster) with create_node.py. You do not touch arguments — an argument about a specific observation already names that observation in affects_observations (step 3), so the consolidator’s script attaches it to the matching edge for you. The consolidator runs that script, then marks a link_state on every observation so nothing is silently unconsidered. Step 5 mints no numbers — the layer reads with zero weights present; step 8 later fills each edge’s likelihood.
Relevant context
You link existing nodes; you create only evidence-links. The types you read:
- observation (
O, step 3) — a finding that follows ~with certainty: paper-derived (generally_knownabsent/false, carrying adata_basisoverride or inheriting its source’s) orgenerally_known(generally_known: true,data_basis: [], so — like any empty-basis observation — never correlated). You readstatement(+ body) and its basis set. - argument (
A, step 3) — astatement, areasoningbody, and its attachmentsaffects_observations([[O-N]], the observations it is about) andaffects_hypotheses([[H-N]]), at least one non-empty. You never read these yourself; the consolidator’s script usesaffects_observationsto wire each argument onto the edges of the observation it names. - hypothesis (
H) — the cluster’s members; you read each one’sstatementto run the diagnosticity test.HC.hypotheseslists onlyactivemembers, so astatecaveat is moot. - hypothesis-cluster (
HC, step 4) — carrieshypotheses, an ordered list of active members (ascending id, catch-all residual last), mutually exclusive. This is thetoof every edge.
Interface
Consumes. From step 4: each HC’s id, its ordered hypotheses list and member statements. From step 3: O.statement + body (the cluster-child). Each observation’s basis set — its data_basis override else its source’s data_basis — and A.affects_observations are read by the consolidator’s scripts, not the cluster-child. Step 5 reads no scores (not trust, usefulness, or validity — step 6 runs after).
Owns. The evidence-link type entirely (cluster-children); the correlation-group (CG) type, each edge’s group and arguments lists, and link_state on every O (the consolidator, via attach_argument_backlinks.py + build_correlation_groups.py then by hand). Writes no basis field and nothing on argument or hypothesis nodes; modifies no other upstream field.
Feeds step 6 (arguments): the edges, so step 6 can find the edge each argument about an observation sits on. Step 5 sets no argument state — the script moves an argument to arguments/orphan/ only when it has no affects_hypotheses and none of its affects_observations sits on a live edge.
Feeds step 8 (likelihoods): each edge (from, to, arguments list, group, any ## Why this is evidence), the correlation-group (CG) nodes, plus HC.hypotheses order. A group link means step 8 writes one joint estimate on that CG node for the whole group, never independent factors.
Feeds step 7 (priors): the edges themselves — step 7 reads the cluster’s evidence-links, may mark some used_for_prior: true as prior-relevant, and separately reads the cluster’s no-observation arguments via their affects_hypotheses.
Spec
Artifact and layout
- One markdown file per node (typed YAML frontmatter + prose body), edited in place.
- Obs edges live in
evidence-links/, prefixE. FilenameE-N - <descriptor>.md, and the descriptor names the two endpoints the child knows at creation — thefromobservation and thetocluster:E-14 - O-14 × HC-1 — youngest ages cluster in the arrival window.md. (Arguments are attached later by script, so they are not in the filename.) Notitlefield; the filename is the title and is fixed at creation (a SKILL.md convention — renaming breaks inbound links). - The edge file is the single canonical record of an obs edge; endpoints get no mirrored link fields. The edge body embeds its observation (
![[O-N]]) so it reads standalone; Obsidian backlinks surface every edge on theOand theHC. - Orphans move, never deleted. The consolidator gives an observation that is the
fromof no edgelink_state: orphanand moves it toobservations-and-facts/orphan/, keeping itslink_stateandsourcelink (provenance); the backlink script likewise moves an argument toarguments/orphan/(keeping itssource) only when it has noaffects_hypothesesand none of itsaffects_observationssits on a live edge. - SHOULD:
evidence-links.base— a derived adjacency view (one row per edge:id, from, to, arguments, group), and optionallycorrelation-groups.base(one row perCG:id, to, members); the notes stay the source of truth. Skip under time pressure.
Schema — the evidence-link node (obs → cluster; the only edge kind)
| field | req | meaning |
|---|---|---|
| type | MUST | evidence-link |
| id | MUST | E-N; fixed at creation |
| from | MUST | [[O-N]] — the observation |
| to | MUST | [[HC-N]] — the hypothesis-cluster |
| arguments | OPTIONAL | [[[A-N]], …] — the arguments about this edge’s observation. Written by the consolidator’s script, not by you (from each argument’s affects_observations); leave it absent when you mint the edge. ≥2 allowed; step 8 composes them |
| group | OPTIONAL | [[CG-N]] — the correlation-group node holding this edge’s joint likelihood, set when its observation shares a data basis with other edges into the same cluster. Written by build_correlation_groups.py at consolidation, not by you; absent for a lone (uncorrelated) edge |
| used_for_prior | OPTIONAL | true if step 7 used this edge for the cluster’s prior (step 8 then skips it); written by step 7, absent here — step 5 never writes it |
Body: the ![[from]] embed + an optional ## Why this is evidence — prose naming which members predict the observation differently and which way, written only where that is not obvious from the observation and the member statements. On a lone edge, step 8 later appends a ## Likelihood section; a grouped edge’s likelihood lives on its correlation-group node instead. Leave the rest of the note to step 8.
Schema — the correlation-group node (CG; built by the consolidator)
One node per connected group of correlated edges into a cluster, minted by build_correlation_groups.py in correlation-groups/ (never by a cluster-child). It is the single home of the group’s joint likelihood.
| field | req | meaning |
|---|---|---|
| type | MUST | correlation-group |
| id | MUST | CG-N; assigned by the script |
| to | MUST | [[HC-N]] — the cluster all members edge into |
| members | MUST | [[[E-N]], …] — the ≥2 evidence-links in the group; their from observations are what step 8’s joint call names |
Body: a one-line note of what the group is; step 8 writes the single ## Likelihood block here. The script owns the frontmatter and never rewrites the body, so a re-run preserves step 8’s block.
Schema — fields step 5 writes on existing nodes
| field | req | on | meaning |
|---|---|---|---|
| link_state | MUST (consolidator) | every O (survivors; not merged/ tombstones) | linked | orphan. linked iff the O is the from of ≥1 edge, else orphan → the file moves to observations-and-facts/orphan/ (nothing deleted). A cluster-child cannot decide this — an observation is an orphan only if no cluster linked it, so the consolidator sets it after every cluster-child returns |
The edge arguments and group fields are also written at consolidation — by attach_argument_backlinks.py and build_correlation_groups.py respectively (see Process). Arguments carry no link_state; an argument’s state is just its folder — the script moves it to arguments/orphan/ only when it has no affects_hypotheses and none of its affects_observations sits on a live edge.
Basis set — read by the consolidator, not the cluster-child. To group correlated edges, build_correlation_groups.py resolves each observation’s basis set: basis(O) = O.data_basis if the observation carries that override (a step-3 field), else its source’s data_basis — each a set of [[D-N]]/[[S-N]] nodes naming the fallible datasets/actors/instruments the finding rests on. A generally_known observation rests on no data (data_basis: []) and is therefore never correlated. The cluster-child neither reads nor writes any basis field; correlation is entirely the consolidator’s.
Rules
- Edge = discrimination, not topical relatedness. Create an obs → cluster edge iff ≥2 of the cluster’s members make materially different predictions about the observation. An observation “about the topic” that every member predicts equally carries no diagnostic weight — leave it unlinked there.
- Paper observations are never merged. Near-duplicate paper observations from different papers stay separate nodes: if they rest on the same data their basis sets share a node and they get flagged correlated; genuinely independent replications rest on distinct basis nodes and correctly count twice. (
generally_knownobservations are the exception — resting on no data, the same fact from two papers is one fact, so step 3’s consolidation already merged such duplicates into a single node.) - Correlation is the consolidator’s job, not yours. You compute and write no correlation. After every cluster-child returns,
build_correlation_groups.pyconnects edges into the same cluster whose observations share adata-basisnode — following the whole connected component, since basis-set intersection is not transitive — and materialises each component of ≥2 edges as acorrelation-group(CG-N) node, writinggroup: [[CG-N]]onto each member edge. This is the guard that stops step 8 treating correlated observations as independent likelihood factors — the standard way Bayesian aggregation manufactures false confidence. - One edge per (from, to) pair — no duplicates. An observation may edge into several clusters. Arguments are not your concern here: the script attaches each argument to every edge whose observation it names, so one argument about one observation lands on that observation’s edge into each cluster automatically.
Micro-example
Main question “What drove the extinction of Sahul’s megafauna around 45–40 ka?”; cluster HC-1 “Dominant driver of the megafauna extinction pulse”, members in id order [H-3 direct predation, H-11 indirect human impact, H-21 climate aridification, H-42 residual].
E-14— fileE-14 - O-14 × HC-1 — youngest ages cluster in the arrival window.md; from[[O-14 - Youngest reliable ages for 8 of 12 genera fall within ~2 kyr of first occupation]]→[[HC-1 - Dominant driver of the megafauna extinction pulse]]; body embeds![[O-14 …]]under## Why this is evidencereading “H-3 predicts the youngest ages pile up inside the arrival window; H-21 predicts they track the aridity maximum instead”. You mint it with nogroupand noargumentsfield — both are added at consolidation: becauseO-14andO-22share basisD-4,build_correlation_groups.pywritesgroup: [[CG-1 …]]onto this edge, and becauseA-7 - repeated arrival waves widen the windownamesO-14in itsaffects_observations,attach_argument_backlinks.pywritesarguments: [[[A-7 …]]].E-22— from[[O-22 - Three genera have reliable ages more than 5 kyr after the arrival window]]→HC-1; minted with nogroup. Both observations derive the basis set{[[D-4 - Continental late-Quaternary age compilation, group R]]}through their shared source (the same continental age compilation), so they intersect onD-4.CG-1—type: correlation-group,to: [[HC-1 - Dominant driver of the megafauna extinction pulse]],members: [[E-14 …]], [[E-22 …]]; built bybuild_correlation_groups.pyincorrelation-groups/becauseE-14andE-22share basisD-4. Step 8 writes the pair’s single joint## Likelihoodblock on this node, and the runner reads it here.O-127—link_state: orphan, moved toobservations-and-facts/orphan/,sourceintact — present-day dietary ecology, which none of the four drivers separate. The argumentA-31 - No comparable extinction pulse is recorded at earlier glacial terminations(emptyaffects_observations, so it reachesHC-1only throughaffects_hypotheses) is left untouched here — it sits on no edge, but step 7 reads it as one ofHC-1’s no-observation arguments.
Checks (binary)
- Every edge sits in
evidence-links/withtype: evidence-link, a uniqueE-Nits filename begins with (E-N - <descriptor>.md), andfrom[[O-N]]+to[[HC-N]]both resolving; the filename names thefrom/toids (not arguments); no (from, to) pair appears twice. - No edge references a
mergedordroppedhypothesis; everytois an existingHC. - Spot-check 3 edges against rule 1: for each, ≥2 members of the target cluster predict the observation differently — readable off the observation and the member statements, or off the edge’s
## Why this is evidence. - After the consolidator runs: every
Oinobservations-and-facts/(excludingmerged/) haslink_state;orphan⟹ the file sits inobservations-and-facts/orphan/withsourceintact;linked⟹fromof ≥1 edge. Every edgeargumentsentry resolves to anAwhoseaffects_observationscontains that edge’sfrom; every argument sits inarguments/if it has anyaffects_hypothesesor anaffects_observationson a live edge, else inarguments/orphan/. O/A/H/HC counts unchanged vs the earlier steps (nothing deleted). - Re-running
build_correlation_groups.pyis a no-op: everycorrelation-groupis exactly one connected component (≥2 edges) of same-cluster edges sharing adata-basisnode, each such edge’sgrouppoints to itsCGand every other edge has none, and eachCG’smembersare those edges. Basis is always[[D-N]]/[[S-N]]links (no free-text). - No step-5-owned field holds a number, probability, weight, or rank; the layer is complete and readable with none present.
- The
check_missing_evidence_links.py --cluster HC-Nsweep has been run for the cluster; every(observation, argument)pair it surfaced is now an edge or a deliberate non-edge (the observation doesn’t discriminate).
Process
The one judgment that matters here is diagnosticity: an observation is evidence on your cluster only where its members would predict it differently — plain Bayesianism. Evidence consistent with every member has no diagnostic value, and the job is to find and record where the hypotheses come apart, not to pile up items “consistent with” a favoured answer. Work by discrimination, not by topic.
You own one cluster, and two things to do on it:
-
Edges. Walk the observations that plausibly touch your cluster’s sub-question — the survivors in
observations-and-facts/, skipping themerged/tombstones step 3’s consolidation left (use topic to prune — most observations bear on at most one cluster, some on none). For each candidate, run the diagnosticity test: sketch, qualitatively,P(observation | member)down thehypotheseslist. If ≥2 members differ materially, mint the edge (viacreate_node.py); if they’d all predict it about equally, don’t — that observation is non-diagnostic here (it may still discriminate another cluster, whose child handles it). Where the discrimination isn’t obvious from reading the observation against the member statements, write it into## Why this is evidence: name which members predict it differently and which way. No numbers — direction only; step 8 prices it. -
Missed-edge sweep. Before you return, run
python3 .claude/skills/flf-epistack/scripts/check_missing_evidence_links.py <analysis-dir> --cluster HC-N— a “what might I have missed” pass. It lists(observation, argument)pairs where an argument ties the observation to a member of your cluster but noO → HCedge exists yet. Add the missing edge where the observation actually discriminates (rule 1); otherwise leave it.
You never place arguments or compute correlation — an argument about an observation reaches its edges from its own affects_observations (step 3), and correlated edges are grouped by shared basis node; both are wired mechanically by the consolidator’s scripts after every cluster-child returns.
Consolidate — arguments, correlation, then link_state, after every cluster-child has returned. A separate final child does this; a cluster-child does none of it. In order: (1) run python3 .claude/skills/flf-epistack/scripts/attach_argument_backlinks.py <analysis-dir> — it writes each edge’s arguments list from the arguments’ affects_observations backlinks and moves an argument to arguments/orphan/ only when it has no affects_hypotheses and none of its affects_observations sits on a live edge; (2) run python3 .claude/skills/flf-epistack/scripts/build_correlation_groups.py <analysis-dir> — it mints the correlation-group (CG) nodes over connected components of edges sharing a basis and writes each member edge’s group (this fixed order after step 1 keeps the edge frontmatter stable across re-runs); (3) set link_state on every O in observations-and-facts/ (excluding the merged/ tombstones) — linked iff it is the from of some edge, else orphan — and move each orphan to observations-and-facts/orphan/ with its source intact. Steps 1-3 are global: an observation is an orphan only if no cluster linked it.
Judgment left to you: how finely to slice “materially different”, when a discrimination is obvious enough to skip the prose, and how hard to hunt for the cluster an odd observation actually discriminates. Prefer a smaller set of genuinely diagnostic edges with clear reasons over broad topical coverage.