///|
priv struct SequenceTrack {
id : Int
observations : Array[SequenceObservation]
}
///|
priv struct SequenceObservation {
frame : Int
interval_index : Int
birth : Double
death : Double?
observed_lifetime : Double
}
///|
fn sequence_optional_number(value : Double?) -> Json {
match value {
Some(number) => number.to_json()
None => Json::null()
}
}
///|
fn sequence_observation(
analysis : Analysis,
frame : Int,
interval_index : Int,
) -> SequenceObservation {
let interval = analysis.intervals[interval_index]
{
frame,
interval_index,
birth: interval.birth,
death: interval.death,
observed_lifetime: observed_lifetime(analysis, interval),
}
}
///|
fn sequence_observation_json(observation : SequenceObservation) -> Json {
{
"frame": observation.frame,
"interval_index": observation.interval_index,
"birth": observation.birth,
"death": sequence_optional_number(observation.death),
"observed_lifetime": observation.observed_lifetime,
"censored": observation.death is None,
}
}
///|
fn sequence_h1_indices(analysis : Analysis) -> Array[Int] {
let indices : Array[Int] = []
for i = 0; i < analysis.intervals.length(); i = i + 1 {
if analysis.intervals[i].dimension == 1 {
indices.push(i)
}
}
indices
}
///|
/// Analyze two to twelve ordered point clouds at one common scale cutoff.
/// H1 tracks use one optimal diagram matching per adjacent pair. Track IDs are
/// diagram correspondences, not verified identities of physical objects.
pub fn topology_sequence(
text : String,
) -> (Json, String, String, String) raise TopologyError {
if text.length() > 1000000 {
raise TopologyError("sequence request exceeds one million characters")
}
let parsed = @json.parse(text) catch {
_ => raise TopologyError("invalid sequence JSON")
}
let root = batch_object(parsed, "sequence request")
batch_fields(root, ["kind", "threshold", "max_cells", "frames"], "sequence")
if batch_string(root, "kind") != "topology_sequence" {
raise TopologyError("kind must be topology_sequence")
}
let threshold = number_field(root, "threshold")
if !finite(threshold) || threshold <= 0.0 || threshold > 1.0e100 {
raise TopologyError("sequence threshold must be finite and positive")
}
let budget = integer_field(root, "max_cells", 4000)
check_budget(0, budget)
let frame_values = batch_array(root, "frames", true)
if frame_values.length() < 2 || frame_values.length() > 12 {
raise TopologyError("sequence requires 2..12 frames")
}
let analyses : Array[Analysis] = []
let names : Array[String] = []
let seen : Map[String, Bool] = Map([])
let mut coordinate_dimension = 0
let mut total_cells = 0
let frames : Array[Json] = []
let mut frames_csv = "frame,name,point_count,cell_count,h0_at_cutoff,h1_at_cutoff,h1_finite_count,h1_alive_at_cutoff,longest_h1_observed_lifetime\n"
for value in frame_values {
let fields = batch_object(value, "sequence frame")
batch_fields(fields, ["name", "points"], "sequence frame")
let name = batch_string(fields, "name")
batch_name(name)
if seen.contains(name) {
raise TopologyError("duplicate sequence frame name: " + name)
}
seen[name] = true
let points = rows_field(fields, "points")
if points.length() < 3 || points.length() > 64 {
raise TopologyError("sequence frame requires 3..64 points")
}
if coordinate_dimension == 0 {
coordinate_dimension = points[0].length()
} else if points[0].length() != coordinate_dimension {
raise TopologyError(
"sequence frames must use the same coordinate dimension",
)
}
let analysis = analyze(rips(points, threshold~, max_cells=budget))
total_cells += analysis.filtration.cells.length()
if total_cells > 16000 {
raise TopologyError("sequence exceeds 16000 total filtration cells")
}
let mut finite_h1 = 0
let mut alive_h1 = 0
let mut longest_h1 = 0.0
for interval in analysis.intervals {
if interval.dimension == 1 {
if interval.death is None {
alive_h1 += 1
} else {
finite_h1 += 1
}
longest_h1 = maximum(longest_h1, observed_lifetime(analysis, interval))
}
}
let h0 = betti(analysis, 0, threshold)
let h1 = betti(analysis, 1, threshold)
let frame_index = frames.length()
frames.push({
"index": frame_index,
"name": name,
"point_count": points.length(),
"cell_count": analysis.filtration.cells.length(),
"h0_at_cutoff": h0,
"h1_at_cutoff": h1,
"h1_finite_count": finite_h1,
"h1_alive_at_cutoff": alive_h1,
"longest_h1_observed_lifetime": longest_h1,
})
frames_csv += "\{frame_index},\{name},\{points.length()},\{analysis.filtration.cells.length()},\{h0},\{h1},\{finite_h1},\{alive_h1},\{longest_h1}\n"
names.push(name)
analyses.push(analysis)
}
let tracks : Array[SequenceTrack] = []
let mut previous : Map[Int, Int] = Map([])
for index in sequence_h1_indices(analyses[0]) {
let id = tracks.length()
tracks.push({
id,
observations: [sequence_observation(analyses[0], 0, index)],
})
previous[index] = id
}
let transitions : Array[Json] = []
let mut transitions_csv = "left_frame,right_frame,left_name,right_name,h0_bottleneck,h1_bottleneck,continued_h1,appeared_h1,disappeared_h1\n"
for frame = 1; frame < analyses.length(); frame = frame + 1 {
let left = analyses[frame - 1]
let right = analyses[frame]
let h0_distance = compare_analyses(left, right, dimension=0)
let h1_distance = compare_analyses(left, right, dimension=1)
let current : Map[Int, Int] = Map([])
let mut continued = 0
let mut appeared = 0
let mut disappeared = 0
for pair in interval_matching(left, right, dimension=1) {
match (pair.left, pair.right) {
(Some(left_index), Some(right_index)) => {
let id = match previous.get(left_index) {
Some(id) => id
None => raise TopologyError("missing previous H1 track")
}
current[right_index] = id
continued += 1
}
(None, Some(right_index)) => {
let id = tracks.length()
tracks.push({ id, observations: [], })
current[right_index] = id
appeared += 1
}
(Some(_), None) => disappeared += 1
(None, None) => ()
}
}
for index in sequence_h1_indices(right) {
let id = match current.get(index) {
Some(id) => id
None => raise TopologyError("missing current H1 track")
}
tracks[id].observations.push(sequence_observation(right, frame, index))
}
transitions.push({
"left_frame": frame - 1,
"right_frame": frame,
"left_name": names[frame - 1],
"right_name": names[frame],
"h0_bottleneck": sequence_optional_number(h0_distance),
"h1_bottleneck": sequence_optional_number(h1_distance),
"continued_h1": continued,
"appeared_h1": appeared,
"disappeared_h1": disappeared,
})
let d0 = match h0_distance {
Some(d) => d.to_string()
None => ""
}
let d1 = match h1_distance {
Some(d) => d.to_string()
None => ""
}
transitions_csv += "\{frame - 1},\{frame},\{names[frame - 1]},\{names[frame]},\{d0},\{d1},\{continued},\{appeared},\{disappeared}\n"
previous = current
}
let track_json : Array[Json] = []
let mut tracks_csv = "track_id,frame,name,interval_index,birth,death,observed_lifetime,censored\n"
for track in tracks {
track_json.push({
"track_id": track.id,
"observation_count": track.observations.length(),
"observations": track.observations.map(sequence_observation_json),
})
for observation in track.observations {
let death = match observation.death {
Some(d) => d.to_string()
_ => ""
}
let censored = if observation.death is None { "true" } else { "false" }
tracks_csv += "\{track.id},\{observation.frame},\{names[observation.frame]},\{observation.interval_index},\{observation.birth},\{death},\{observation.observed_lifetime},\{censored}\n"
}
}
(
{
"schema_version": 1,
"kind": "topology_sequence",
"threshold": threshold,
"coordinate_dimension": coordinate_dimension,
"frame_count": frames.length(),
"total_cells": total_cells,
"track_count": tracks.length(),
"frames": frames,
"transitions": transitions,
"tracks": track_json,
"interpretation": "adjacent H1 tracks follow one optimal persistence-diagram matching; matches may be nonunique and do not prove physical identity; diagonal matches describe diagram appearance or disappearance, not object creation; null distance means incompatible counts of cutoff-censored classes",
},
frames_csv,
transitions_csv,
tracks_csv,
)
}