Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions frontend/src/api/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,25 @@ export type SSEHandler = {
export function openEventStream(handlers: SSEHandler & { onConnectedChange?: (connected: boolean) => void }): () => void {
const es = new EventSource(apiUrl('/api/v1/events'))

// A malformed frame must not throw inside the listener: EventSource would
// keep the stream open but the exception surfaces as an uncaught error and
// the update is lost silently either way. Drop the bad frame instead.
const parseFrame = (data: string): unknown => {
try {
return JSON.parse(data)
} catch {
return undefined
}
}

es.addEventListener('job_update', (e: MessageEvent) => {
handlers.onJobUpdate?.(JSON.parse(e.data) as Job)
const job = parseFrame(e.data)
if (job !== undefined) handlers.onJobUpdate?.(job as Job)
})

es.addEventListener('stage_update', (e: MessageEvent) => {
handlers.onStageUpdate?.(JSON.parse(e.data))
const ev = parseFrame(e.data)
if (ev !== undefined) handlers.onStageUpdate?.(ev as Parameters<NonNullable<SSEHandler['onStageUpdate']>>[0])
})

es.onopen = () => handlers.onConnectedChange?.(true)
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -968,6 +968,17 @@ export interface CladeCumulusRequest {
export interface CladeCumulusDiversity {
samples: string[]
mapped_tips: number
/**
* Reads per sample that no tip on the tree carries: a query whose kept
* `.jplace` like-weight ratios do not reach 1 contributes only that share,
* and an ASV the tree does not cover contributes nothing.
*
* These reads are excluded from the tree-tip abundances, but they are still
* counted in each sample's library size, which is the denominator the
* weighted UniFrac metrics divide by. That is what keeps the shortfall off
* the tree instead of renormalising a partially placed sample back up to
* abundance 1. Absent for responses produced before this field existed.
*/
unplaced_reads?: Record<string, number>
faith_pd: Record<string, number>
unweighted_unifrac: number[][]
Expand Down
27 changes: 25 additions & 2 deletions frontend/src/components/CladeCumulusPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ export interface CladeCumulusPanelProps {
runs: CladeCumulusRunOption[]
}

/**
* Explore a study run's clade hierarchy, diversity and unplaced reads. Loads
* available sources and computes results on request; move checks can preview
* counterfactual trees or save sidecar proposals without changing canonical tables.
*/
export function CladeCumulusPanel({ study, runs }: CladeCumulusPanelProps) {
const toast = useToast()
const reqCounter = useRef(0)
Expand Down Expand Up @@ -249,6 +254,9 @@ export function CladeCumulusPanel({ study, runs }: CladeCumulusPanelProps) {
return div.normalized_weighted_unifrac
}, [result, unifracKind])

// Only samples that actually lost mass are worth naming; a run with a
// complete placement file (or one produced before the field existed) reports
// nothing here.
const unplacedSamples = useMemo<[string, number][]>(() => {
const div = result?.diversity
if (!div?.unplaced_reads) return []
Expand Down Expand Up @@ -708,10 +716,25 @@ export function CladeCumulusPanel({ study, runs }: CladeCumulusPanelProps) {
))}
</div>

{/*
Reads no tip carries: like-weight mass a placement left on no
edge, plus ASVs the tree does not cover. They are absent from
the tree-tip abundances but they are *not* dropped from the
sample: each one still counts in its sample's library size,
which is the denominator the weighted UniFrac metrics divide
by. That is what keeps them off the tree instead of letting a
partially placed sample renormalise back up to abundance 1, so
the shortfall is stated here as well.
*/}
{unplacedSamples.length > 0 && (
<p style={{ fontSize: '.75rem', margin: '0 0 12px', color: 'var(--color-fg-muted, #57606a)' }}>
Unplaced reads (like-weight mass left off reference edges, excluded from weighted abundances above):{' '}
{unplacedSamples.map(([s, n]) => `${s} ${n.toFixed(2)}`).join(', ')}
Unplaced reads (carried by no tip — like-weight mass left on no edge, plus
ASVs the tree does not cover — excluded from the tree-tip abundances but
counted in each sample’s library size, so they lower its relative
abundances rather than being renormalised onto the placed tips):{' '}
{unplacedSamples
.map(([s, n]) => `${s} ${n.toFixed(2)}`)
.join(', ')}
</p>
)}

Expand Down
21 changes: 16 additions & 5 deletions frontend/src/tree/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,14 +112,14 @@ export function parseNewick(text: string): SourceTree {
skipSpace()
if (s[i] === "'" || s[i] === '"') {
const q = s[i++]
let out = ''
const parts: string[] = []
while (i < s.length) {
if (s[i] === q) {
if (s[i + 1] === q) { out += q; i += 2; continue }
if (s[i + 1] === q) { parts.push(q); i += 2; continue }
i++
return out
return parts.join('')
}
out += s[i++]
parts.push(s[i++])
}
fail('unclosed quote')
}
Expand Down Expand Up @@ -209,7 +209,18 @@ export function parseNewick(text: string): SourceTree {
}

export function parseJplace(text: string): SourceTree {
const doc = JSON.parse(text)
let doc: {
tree?: unknown
fields?: string[]
placements?: Record<string, unknown>[]
metadata?: Record<string, unknown>
version?: unknown
}
try {
doc = JSON.parse(text)
} catch {
throw new Error('jplace file is not valid JSON')
}
const tree = parseNewick(String(doc.tree))
const fields: string[] = doc.fields ?? []
const col = (f: string) => fields.indexOf(f)
Expand Down
9 changes: 8 additions & 1 deletion scripts/check-julia-syntax.jl
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,15 @@ for file in files
issue = ex
break
end
# A bare `end` at the top level parses without error and then fails
# at lowering (`syntax: "end" at top level`), which is how a file
# that lost its `module Foo` opener reaches CI looking syntactically
# fine. An `end` balances exactly one opener, so top level cannot
# hold one: this is a missing or duplicated opener, and the file
# would not load.
if ex === :end
issue = "stray `end` at top level (missing or duplicated opener in this file)"
issue = "stray `end` at top level (a `module`/`function`/... opener " *
"is missing or was duplicated in this file)"
break
end
next_position > position ||
Expand Down
Loading
Loading