Skip to content

Root the indexed tree before computing Faith's PD (Codex P1 on #234) - #238

Merged
hyperpolymath merged 2 commits into
mainfrom
arena/a4bb67df-metamanifold-webui
Oct 23, 2026
Merged

hyperpolymath merged 2 commits into
mainfrom
arena/a4bb67df-metamanifold-webui

Conversation

@hyperpolymath

Copy link
Copy Markdown
Owner

Closes the last substantive finding on #234.

The finding

Codex P1 — "Root the IQ-TREE output before computing rooted metrics" — and CodeRabbit major — "Define and enforce one rooting convention for Faith's PD" — both flagged the same thing, and both are right.

step_commands runs IQ-TREE with a reversible substitution model, which infers an unrooted tree, so the root of reference.treefile is only whichever node IQ-TREE happened to serialize first. analyse_phylogeny used that node directly, while the legacy faith_pd(counts, taxa, tree) path midpoint-roots first (_ensure_rooted_tree). UniFrac is unaffected — it sums edge partitions, and re-rooting preserves them — but Faith's PD is a path-to-root quantity:

tree: (A:1.0,B:2.0,C:3.0);   sample: A only
legacy faith_pd(...)         -> 1.5   (midpoint-rooted)
indexed analyse_phylogeny    -> 1.0   (arbitrary serialized root)

an equivalent rerooted serialization of the same inferred tree could give a different number again.

The fix

New ensure_rooted(::PhylogeneticTree), using exactly the legacy convention: a root with more than two children is midpoint-rooted by the existing midpoint_root, and an already bifurcating tree is returned untouched (identical object). To avoid reimplementing an algorithm that is already verified against scikit-bio reference outputs, _to_phylo/_from_phylo convert to PhyloNode and back; the round trip preserves tip names, internal labels, branch lengths and the parents-precede-children ordering the roll-up depends on.

  • analyse_phylogeny, faith_pd(tree, counts, tip_names) and unifrac(tree, counts, tip_names) root before rolling up, so the pipeline and the legacy API can no longer disagree.
  • The roll-up overloads (faith_pd(tree, cumulative), unifrac(tree, cumulative)) take a matrix and cannot root it for you; their docstring now says the roll-up must come from an already rooted tree.
  • The cladistics results.json records the convention in a rooting field alongside weighted_unifrac_definition, mirrored as optional CladisticsResult.rooting in the frontend types.
  • docs/cladistics/clade-cumulus.md documents the convention and why the two metrics differ.

Tests

  • An unrooted trifurcating tree roots to two children, keeps its tips, and is returned identically when already rooted.
  • PD for a lone tip is 1.5 — and equal to faith_pd(counts, taxa, tree) on the same Newick string, which is the parity the finding asked for; the unrooted roll-up is asserted to be the wrong 1.0.
  • A rooted and an unrooted serialization of the same inferred tree agree on faith_pd, unweighted_unifrac, weighted_unifrac and total_branch_length (6.0).

The only other #234 finding, CodeRabbit's docstring nit on weighted=true being normalised weighted UniFrac, is already on main (it landed in 833c420).

Codex (P1) and CodeRabbit (major) both flagged this on #234: IQ-TREE infers
an *unrooted* tree for a reversible model, so the root of a
`reference.treefile` is only whichever node happened to be serialized first.
`analyse_phylogeny` used that root directly, while the legacy
`faith_pd(counts, taxa, tree)` path midpoint-roots first via
`_ensure_rooted_tree`. UniFrac is unaffected — it sums edge partitions and
re-rooting preserves them — but Faith's PD is a path-to-root quantity, so for
a sample with a single active tip the indexed path reported 1.0 where the
legacy path reported 1.5 for the same tree, and an equivalent rerooted
serialization could give a different number again.

`ensure_rooted` now applies the legacy convention to `PhylogeneticTree`: a
root with more than two children is midpoint-rooted by `midpoint_root` (the
implementation already verified against scikit-bio reference outputs) and an
already bifurcating tree is returned untouched. To reuse that audited code
without reimplementing it, `_to_phylo`/`_from_phylo` convert to `PhyloNode`
and back; the round trip preserves labels, tip names, lengths and the
parents-precede-children ordering the roll-up relies on.

`analyse_phylogeny`, `faith_pd(tree, counts, tip_names)` and
`unifrac(tree, counts, tip_names)` root before rolling up; the roll-up
overloads take a matrix, so they document that it must come from an already
rooted tree. The cladistics `results.json` now records the convention in a
`rooting` field, mirrored as an optional `CladisticsResult.rooting` in the
frontend types.

Tests pin the convention: an unrooted trifurcating tree roots to two children,
PD for a lone tip matches the legacy `faith_pd` path exactly (1.5, versus the
1.0 the raw serialization gives), and a rooted and an unrooted serialization
of the same inferred tree agree on `faith_pd`, both UniFrac matrices and
`total_branch_length`.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 33 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: a88894ee-a80d-4833-b878-fa228044bdf8
📥 Commits

Reviewing files that changed from the base of the PR and between bb40ab0 and f484463.

📒 Files selected for processing: 5
  • docs/cladistics/clade-cumulus.md
  • frontend/src/api/types.ts
  • src/analysis/clade_cumulus.jl
  • src/server/routes/cladistics.jl
  • test/unit/test_clade_cumulus.jl
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-23T15:29:07.390183Z 5a92c06 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Fork governance

Lane Status Run
DEED syntax (repo deed) 🟢 conformant run 39978318819
Hypatia verdict grid purple (job: success) same run

Hypatia grid purple

🟣 critical · commit f484463bd63b · run 39978318819

Reporter Status Critical High Medium Minor Since previous revision
Hypatia scan 🟣 critical 45 61 35 0 ↔ unchanged
panic-attack assail 🔴 high 0 6 11 0 ↑ fewer medium
pons asinorum 🟡 medium 0 0 8 0 ↑ fewer medium
rhodibot (RSR compliance) 🟠 indeterminate 0 0 0 0 — indeterminate
panicbot (panic recovery, error handling) 🟠 indeterminate 0 0 0 0 — indeterminate
echidnabot (formal verification: proofs/) 🟠 indeterminate 0 0 0 0 — indeterminate
seambot (integration health: API routes) 🟠 indeterminate 0 0 0 0 — indeterminate
finishingbot (release readiness) 🟠 indeterminate 0 0 0 0 — indeterminate
accessibilitybot (WCAG on the React UI) 🟠 indeterminate 0 0 0 0 — indeterminate
glambot (presentation quality) 🟠 indeterminate 0 0 0 0 — indeterminate
cipherbot ⚪ not applicable 0 0 0 0 ↔ unchanged
sustainabot ⚪ not applicable 0 0 0 0 ↔ unchanged
Hypatia scan: 141 finding(s)
  • critical cicd_rules/banned_language_file — frontend/src/views/run/parseFilterYaml.ts — TypeScript file detected -- banned language
  • critical cicd_rules/banned_language_file — frontend/src/figure/encode.ts — TypeScript file detected -- banned language
  • critical cicd_rules/banned_language_file — frontend/src/figure/charts.ts — TypeScript file detected -- banned language
  • critical cicd_rules/banned_language_file — frontend/src/figure/export.ts — TypeScript file detected -- banned language
  • critical cicd_rules/banned_language_file — frontend/src/figure/figure.test.ts — TypeScript file detected -- banned language
  • critical cicd_rules/banned_language_file — frontend/src/figure/legend.ts — TypeScript file detected -- banned language
  • critical cicd_rules/banned_language_file — frontend/src/figure/types.ts — TypeScript file detected -- banned language
  • critical cicd_rules/banned_language_file — frontend/src/utils/download.ts — TypeScript file detected -- banned language
  • critical cicd_rules/banned_language_file — frontend/src/utils/text.test.ts — TypeScript file detected -- banned language
  • critical cicd_rules/banned_language_file — frontend/src/utils/timeago.ts — TypeScript file detected -- banned language
  • critical cicd_rules/banned_language_file — frontend/src/utils/text.ts — TypeScript file detected -- banned language
  • critical cicd_rules/banned_language_file — frontend/src/hooks/useUnsavedGuard.ts — TypeScript file detected -- banned language
  • critical cicd_rules/banned_language_file — frontend/src/hooks/useApi.test.ts — TypeScript file detected -- banned language
  • critical cicd_rules/banned_language_file — frontend/src/hooks/useApi.ts — TypeScript file detected -- banned language
  • critical cicd_rules/banned_language_file — frontend/src/hooks/useAnalysis.ts — TypeScript file detected -- banned language
  • critical cicd_rules/banned_language_file — frontend/src/hooks/useSSE.ts — TypeScript file detected -- banned language
  • critical cicd_rules/banned_language_file — frontend/src/hooks/useTabParam.ts — TypeScript file detected -- banned language
  • critical cicd_rules/banned_language_file — frontend/src/hooks/useNavRefresh.ts — TypeScript file detected -- banned language
  • critical cicd_rules/banned_language_file — frontend/src/hooks/useJobEvents.ts — TypeScript file detected -- banned language
  • critical cicd_rules/banned_language_file — frontend/src/about.ts — TypeScript file detected -- banned language
  • 121 more; see the structured report in the artifact.
panic-attack assail: 17 finding(s)
  • high DynamicCodeExecution — src/pipeline/dada2/context.jl — eval/Meta.parse in src/pipeline/dada2/context.jl
  • high DynamicCodeExecution — src/analysis/analysis.jl — eval/Meta.parse in src/analysis/analysis.jl
  • high DynamicCodeExecution — src/analysis/differential.jl — eval/Meta.parse in src/analysis/differential.jl
  • high DynamicCodeExecution — src/core/provenance.jl — eval/Meta.parse in src/core/provenance.jl
  • high DynamicCodeExecution — scripts/check-julia-syntax.jl — eval/Meta.parse in scripts/check-julia-syntax.jl
  • high DynamicCodeExecution — test/unit/test_permanova.jl — eval/Meta.parse in test/unit/test_permanova.jl
  • medium UnsafeFFI — src/core/memory_probe.jl — 3 ccall/FFI calls in src/core/memory_probe.jl
  • medium InputBoundary — src/server/routes/runs.jl — 2 JSON3.read/JSON.parse call(s) with 0 try block(s) in src/server/routes/runs.jl — these throw on malformed input; wrap in try/catch
  • medium InputBoundary — src/server/routes/composition.jl — 6 JSON3.read/JSON.parse call(s) with 2 try block(s) in src/server/routes/composition.jl — these throw on malformed input; wrap in try/catch
  • medium InputBoundary — src/server/routes/studies.jl — 4 JSON3.read/JSON.parse call(s) with 2 try block(s) in src/server/routes/studies.jl — these throw on malformed input; wrap in try/catch
  • medium InputBoundary — src/server/routes/analysis.jl — 9 JSON3.read/JSON.parse call(s) with 4 try block(s) in src/server/routes/analysis.jl — these throw on malformed input; wrap in try/catch
  • medium UnsafeFFI — install.jl — 1 ccall/FFI calls in install.jl
  • medium ProofDrift — test/unit/test_occupancy_grade.jl — 2 `@test x isa Y` assertion(s) with no value check — type-only tests may substitute for an unwritten formal theorem in test/unit/test_occupancy_grade.jl
  • medium ProofDrift — test/unit/test_evidence.jl — 1 `@test x isa Y` assertion(s) with no value check — type-only tests may substitute for an unwritten formal theorem in test/unit/test_evidence.jl
  • medium InputBoundary — bench/advanced_zero_handling/benchmark.jl — 1 JSON3.read/JSON.parse call(s) with 0 try block(s) in bench/advanced_zero_handling/benchmark.jl — these throw on malformed input; wrap in try/catch
  • medium InputBoundary — frontend/src/figure/charts.ts — 1 JSON.parse call(s) with 0 try block(s) in frontend/src/figure/charts.ts — JSON.parse throws SyntaxError on malformed input; wrap in try-catch
  • medium InputBoundary — frontend/src/components/alphaMetrics.tsx — 1 JSON.parse call(s) with 0 try block(s) in frontend/src/components/alphaMetrics.tsx — JSON.parse throws SyntaxError on malformed input; wrap in try-catch
pons asinorum: 8 finding(s)
  • medium string-concat-in-loop — ./frontend/src/tree/TreeShapes.tsx:69 — string built by repeated concatenation in a loop (T0, HEURISTIC)
  • medium string-concat-in-loop — ./frontend/src/tree/TreeShapes.tsx:66 — string built by repeated concatenation in a loop (T0, HEURISTIC)
  • medium string-concat-in-loop — ./frontend/src/tree/TreeShapes.tsx:61 — string built by repeated concatenation in a loop (T0, HEURISTIC)
  • medium string-concat-in-loop — ./frontend/src/tree/TreeShapes.tsx:53 — string built by repeated concatenation in a loop (T0, HEURISTIC)
  • medium string-concat-in-loop — ./frontend/src/tree/TreeShapes.tsx:43 — string built by repeated concatenation in a loop (T0, HEURISTIC)
  • medium string-concat-in-loop — ./frontend/src/tree/TreeShapes.tsx:42 — string built by repeated concatenation in a loop (T0, HEURISTIC)
  • medium unreachable-after-jump — ./frontend/src/figure/PaneEditor.tsx:45 — statement is unreachable after an unconditional jump (T0, HEURISTIC)
  • medium unreachable-after-jump — ./frontend/src/components/PublicationTablesPanel.tsx:208 — statement is unreachable after an unconditional jump (T0, HEURISTIC)
rhodibot (RSR compliance): not_run — enrolled, but no report-back was received
  • 🟠 indeterminate: enrolled, but no report-back was received
panicbot (panic recovery, error handling): not_run — enrolled, but no report-back was received
  • 🟠 indeterminate: enrolled, but no report-back was received
echidnabot (formal verification: proofs/): not_run — enrolled, but no report-back was received
  • 🟠 indeterminate: enrolled, but no report-back was received
seambot (integration health: API routes): not_run — enrolled, but no report-back was received
  • 🟠 indeterminate: enrolled, but no report-back was received
finishingbot (release readiness): not_run — enrolled, but no report-back was received
  • 🟠 indeterminate: enrolled, but no report-back was received
accessibilitybot (WCAG on the React UI): not_run — enrolled, but no report-back was received
  • 🟠 indeterminate: enrolled, but no report-back was received
glambot (presentation quality): not_run — enrolled, but no report-back was received
  • 🟠 indeterminate: enrolled, but no report-back was received
cipherbot: not_applicable — no cryptography is implemented in this repository
  • ⚪ not applicable: no cryptography is implemented in this repository
sustainabot: not_applicable — fleet adapter slot reserved; no adapter exists yet (gitbot-fleet README)
  • ⚪ not applicable: fleet adapter slot reserved; no adapter exists yet (gitbot-fleet README)

Scope: static findings reported by each row's tool at the named commit. Purple, red, yellow and blue grade the worst finding present; orange is evidence missing, malformed or a scanner that did not run; grey is an explicit scope exclusion with its reason. No row is a security certification. report.json, badge.json and the raw findings are in the verdict-grid artifact on the linked run.

@hyperpolymath
hyperpolymath merged commit f0a4ad5 into main Oct 23, 2026
11 checks passed
@hyperpolymath
hyperpolymath deleted the arena/a4bb67df-metamanifold-webui branch October 23, 2026 15:27
@sonarqubecloud

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5a92c06569

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# IQ-TREE infers an unrooted tree for a reversible model, so PD is only
# meaningful relative to a stated rooting convention; record it next to the
# other metric definitions rather than leaving it implicit.
result["rooting"] = "midpoint (unrooted trees; Faith PD is root-dependent, UniFrac is not)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Describe normalized weighted UniFrac as root-dependent

When consumers interpret the stored provenance for weighted_unifrac, this statement is incorrect: the indexed weighted metric is normalized by sum(branch * (a+b)), and rerooting complements descendant proportions on affected edges, which can change that denominator even though the numerator and unweighted UniFrac remain invariant. Restrict the invariance claim to unweighted/raw weighted UniFrac or state that the normalized weighted result also follows the recorded midpoint-rooting convention.

Useful? React with 👍 / 👎.

Comment on lines +299 to +300
function ensure_rooted(tree::PhylogeneticTree)::PhylogeneticTree
length(tree.nodes[tree.root].children) <= 2 && return tree

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not infer rootedness solely from root degree

For a genuinely rooted multifurcating input such as [&R](A:1,B:2,C:3);, the indexed parser discards the rooting comment and this condition treats the three-child root as unrooted, silently midpoint-rerooting it and changing Faith's PD and the reported clades. Root arity is not a rootedness indicator—a degree-two serialization can likewise represent an unrooted tree—so rootedness needs to be retained explicitly or supplied by the caller rather than inferred here.

Useful? React with 👍 / 👎.

Comment on lines +314 to +315
PhyloNode("n$id", node.name, is_root ? 0.0 : something(node.length, 0.0),
nothing, nothing, children)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve support labels on their branches during rerooting

When midpoint rooting reverses an edge carrying an IQ-TREE internal support label, this conversion stores that label as the vertex name while passing nothing as the edge support. midpoint_root therefore leaves the label attached to the original vertex instead of moving it with the supported bipartition, and _from_phylo can emit the support value on a different clade in the cumulative tree. Parse internal support labels into edge metadata and restore them on the corresponding rerooted edge.

Useful? React with 👍 / 👎.

"""
function ensure_rooted(tree::PhylogeneticTree)::PhylogeneticTree
length(tree.nodes[tree.root].children) <= 2 && return tree
rooted, changed = midpoint_root(_to_phylo(tree))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle zero-diameter trees before midpoint rooting

When an unrooted tree has zero length on every edge—a valid parser input and a possible result when trimming leaves identical sequences—this call reaches midpoint_root, whose strict farthest-tip comparisons select the same tip as both diameter endpoints and then index path[2], throwing a BoundsError. The indexed analysis previously accepted zero branch lengths and produced zero-valued metrics, so handle a zero diameter explicitly rather than failing the whole cladistics build.

Useful? React with 👍 / 👎.

hyperpolymath added a commit that referenced this pull request Oct 23, 2026
…green Julia tests on main) (#240)

`main` is red on `Julia tests` — 5727 pass, **1 fail**, 0 error — and it
was already red when #239 merged: that PR's own `Julia tests` check
failed on the same line.

```
Dual-hierarchy CladeCumulus, polyphyly detection, drag-and-drop, and HTTP routes: Test Failed at test/unit/test_clade_cumulus.jl:730
  Expression: ≈((jp_body.diversity.weighted_unifrac[1])[2], 0.15636363636363637, atol = 1.0e-9)
   Evaluated: 0.1378181818181818 ≈ 0.15636363636363637
```

## Which number is right

The expectation predates #239's own fix, which its description states as
"`totals`/`total_a`/`total_b` library-size denominators". The route now
divides by each sample's 55-read library instead of the 34 and 20 reads
that happened to reach the tree, which is the whole point of that change
— otherwise the unplaced mass is renormalised back onto the tips.

Working the fixture out from that definition, for the grafted tree of
`((Ref_1:0.10{0},Ref_2:0.20{1})0.96:0.30{2},Ref_3:0.40{3}){4};` with
ASV_1 placed at LWR 0.60 on edge 0 (`distal 0.05`, `pendant 0.02`) and
ASV_2 at LWR 1.00 on edge 1:

| edge | length | SampleA (24 / 10 placed) | SampleB (20 placed) |
| --- | --- | --- | --- |
| ASV_1 pendant | 0.02 | 24/55 | 0 |
| Ref_1 remainder | 0.05 | 24/55 | 0 |
| ASV_2 pendant | 0.02 | 10/55 | 20/55 |
| Ref_2 remainder | 0.15 | 10/55 | 20/55 |
| joining clade | 0.30 | 34/55 | 20/55 |
| Ref_3 | 0.40 | 0 | 0 |

```
raw weighted UniFrac = (0.02*24 + 0.05*24 + 0.02*10 + 0.15*10 + 0.30*(34-20)) / 55
                     = 7.58 / 55 = 0.1378181818...
```

That is exactly what the route returns, so the code agrees with its
documented definition and the constant is stale. Nothing else in the
block changes: `mapped_tips == 2`, `unplaced_reads` 21/35 and
`root.cumulative_count ≈ 110` all still hold, and no other test in the
suite fails.

## Change

One test assertion. It now spells out the arithmetic — `≈ 7.58 / 55`
with the edge-length derivation in a comment — so a future convention
change has to argue with the definition rather than with a bare decimal.
No production code is touched.

## Verified

The full suite on this branch is CI; locally, tree-sitter parses the
edited statement and `git diff` shows only the assertion. Everything
else on `main` (including #238's rooting tests) passed in the run that
caught this.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant