Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0f277cb
Add validated statistics layer: catalogue, cancellation, UI, evidence
Oct 23, 2026
5ebe255
Replace the Python mutation driver with Julia; move tool fetches into…
Oct 23, 2026
abf91f9
Fix do-block binding in reference computations; annotate evidence fai…
Oct 23, 2026
e6d7e1f
Instantiate the Julia project in the statistics standards job
Oct 23, 2026
ac68ec9
Fix the mutation runner's working directory and give the standards job R
Oct 23, 2026
a7c74a9
Fix a stray token in the deadline docstring and a generator join
Oct 23, 2026
3cbda41
Move the standards job's R install and log handling into tested scripts
Oct 23, 2026
53839d7
Pin HTTPS redirects on the CRAN key fetch and tidy the CI scripts
Oct 23, 2026
1c22ba7
Merge branch 'main' into arena/3d3fe618-metamanifold-webui
hyperpolymath Oct 23, 2026
ec7496b
Fix two runtime faults the first complete CI run exposed
Oct 23, 2026
80ec3da
Merge remote-tracking branch 'origin/main' into arena/3d3fe618-metama…
Oct 23, 2026
df19ad1
Make the display and the permutation property say what they mean
Oct 23, 2026
2c07f6d
Make the digest print the reason, and fix two test expectations
Oct 23, 2026
2243942
Fix the two faults the improved digest named
Oct 23, 2026
e3f8e41
Name the accepted input in the count-refusal assertions
Oct 23, 2026
5200165
Test the count refusals that the JSON layer actually sees
Oct 23, 2026
0f62060
Merge branch 'main' into arena/3d3fe618-metamanifold-webui
hyperpolymath Oct 23, 2026
8d3c295
Test the ordination fractions as a set, and F as a ratio
Oct 23, 2026
dcb7f1c
Merge remote-tracking branch 'origin/main' into arena/3d3fe618-metama…
Oct 23, 2026
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
23 changes: 18 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -416,15 +416,28 @@ jobs:
# values are what identify the bug - then the end of the log.
# `set +e` above is deliberate: diagnostic helpers may find no
# matches, and only the captured test status controls this step.
grep -E "Test Failed at |Error During Test at |^ Expression: |^ Evaluated: |^ Test threw exception" \
/tmp/validated-statistics-tests.log > /tmp/validated-statistics-failures.log
# One annotation cannot hold a failing run, and a tail hides every
# failure but the last one. Pair each location with its assertion,
# cut long lines (an evaluated HTML page is useless in a digest),
# then publish the first few chunks.
# Print each failure's location and the lines that explain it: the
# expression and evaluated value for an assertion, the exception for
# an error. Long lines are cut because an evaluated HTML page would
# otherwise fill the whole digest.
awk '
/Test Failed at / { print; remaining = 2; next }
/Error During Test at / { print; remaining = 4; next }
/statistics UI request failed/ { print; remaining = 6; next }
remaining > 0 { print; remaining-- }
' /tmp/validated-statistics-tests.log |
cut -c1-260 > /tmp/validated-statistics-failures.log
rm -f /tmp/validated-statistics-digest-*
split -b 2500 /tmp/validated-statistics-failures.log /tmp/validated-statistics-digest-
split -b 3800 /tmp/validated-statistics-failures.log /tmp/validated-statistics-digest-
index=0
for chunk in /tmp/validated-statistics-digest-*; do
[ -e "$chunk" ] || continue
[[ -e "$chunk" ]] || continue
index=$((index + 1))
[ "$index" -le 8 ] || break
[[ "$index" -le 8 ]] || break
echo "::error title=Validated-statistics failure digest $index::$(enc < "$chunk")"
done
echo "::error title=Validated-statistics log tail::$(tail -c 3000 /tmp/validated-statistics-tests.log | enc)"
Expand Down
6 changes: 5 additions & 1 deletion docs/statistics/numeric-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ bytes, per `hyperpolymath/standards` JSON-POLICY (D306) and the
- JSON numbers carry exactly the safe I-JSON integer range
`-(2^53-1) .. 2^53-1`.
- **A count above `2^53-1` is a decimal string, never a JSON number.** `1` is a
number; `"9007199254740993"` is a string.
number; `"9007199254740993"` is a string. A JSON number outside
`-(2^53-1) .. 2^53-1` is refused by the canonical reader as a `400`: the
document itself is not I-JSON, so the statistics layer never sees a count it
would have to round. The same magnitude sent as a whole-number string is
accepted and analysed exactly.
- **Decimal and exponent tokens are refused even when their value is
integral.** `1.0` and `1e0` are float tokens for a field that is
contractually integral; accepting them would silently claim a precision the
Expand Down
2 changes: 1 addition & 1 deletion scripts/statistics/simulation_study.jl
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ function main(args::Vector{String})
rng = MersenneTwister(seed)
exact_failures = String[]
metric_failures = String[]
maxima = Dict(Int, Dict{String,BigFloat})(
maxima = Dict{Int,Dict{String,BigFloat}}(
bits => Dict("shannon_abs" => big(0.0), "shannon_rel" => big(0.0),
"simpson_abs" => big(0.0), "simpson_rel" => big(0.0))
for bits in PRECISIONS)
Expand Down
5 changes: 4 additions & 1 deletion src/analysis/numeric_policy.jl
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,10 @@ function to_display(v::NumericValue; digits::Integer=6)
kept = v.kind === ROUNDED && !isnothing(v.digits) ? v.digits : Int(digits)
rendered = _rendered_decimal(Rational{BigInt}(v.value), kept)
bits = isnothing(v.precision_bits) ? "unknown precision" : "$(v.precision_bits)-bit"
"$rendered ($(v.kind), $bits, $(kept)dp)"
# The display is always a rounding, whatever the value's kind: an
# approximate MPFR value shown at 6dp has still been rounded, and a reader
# must not mistake the short string for the stored value.
"$rendered ($(v.kind), $bits, rounded to $(kept)dp)"
end

function encode_json_number(x)
Expand Down
7 changes: 5 additions & 2 deletions src/server/routes/statistics_ui.jl
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ using ..DerivedAnalyses
const _STATISTICS_UI_TABLE_CELLS = 20_000
const _STATISTICS_UI_TITLE = "Validated statistics"

# `string`, not `String`: the storage dictionaries carry integers as JSON
# numbers, and `String(::Int)` is a MethodError. Every value that reaches the
# page is rendered, so accepting any printable value here is the contract.
function _html_escape(value)
replace(String(value),
replace(string(value),
'&' => "&amp;", '<' => "&lt;", '>' => "&gt;",
'"' => "&quot;", '\'' => "&#39;")
end
Expand Down Expand Up @@ -348,7 +351,7 @@ function _ui_high_precision_value(value)
display = get(value, "display", "")
bits = get(value, "precision_bits", "")
kind = get(value, "kind", "approximate")
String("<code>", _html(print_value), "</code><br><span class=\"display\">",
string("<code>", _html(print_value), "</code><br><span class=\"display\">",
_html(kind), " · ", _html(string(bits)), "-bit · display: ",
_html(display), "</span>")
end
Expand Down
7 changes: 5 additions & 2 deletions test/unit/test_method_catalogue.jl
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,12 @@ end
@test rejected(doc -> doc["methods"] = Any[])
@test rejected(doc -> doc["methods"][1]["status"] = "maybe")
@test rejected(doc -> doc["methods"][1]["evidence"] = Any[])
# Flipping an unsupported entry to supported without evidence is refused.
# Flipping an unsupported entry to supported is refused: it has no
# implementation and no evidence. Clearing the evidence of a *supported*
# entry is refused too, which is why the second mutation targets entry 2:
# an unsupported entry has no evidence to clear, and removing it is legal.
@test rejected(doc -> doc["methods"][3]["status"] = "supported")
@test rejected(doc -> doc["methods"][3]["evidence"] = Any[])
@test rejected(doc -> doc["methods"][2]["evidence"] = Any[])
@test rejected(doc -> doc["methods"][1]["id"] = doc["methods"][2]["id"])
@test rejected(doc -> doc["limits"]["max_cells"] = MAX_CELLS + 1)
@test rejected(doc -> doc["limits"]["max_precision_bits"] = MAX_PRECISION_BITS + 1)
Expand Down
2 changes: 1 addition & 1 deletion test/unit/test_numeric_policy.jl
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ end
@test occursin("approximate", to_display(approx))
rounded = rounded_value(1.0/3; digits=6)
@test rounded.kind == ROUNDED
@test to_display(rounded) == "0.333333 (rounded, 53-bit, 6dp)"
@test to_display(rounded) == "0.333333 (rounded, 53-bit, rounded to 6dp)"
@test_throws UnsupportedRepresentationError to_storage(approximate_value(NaN))
@test_throws UnsupportedRepresentationError encode_json_number(Inf)

Expand Down
38 changes: 32 additions & 6 deletions test/unit/test_statistics_adversarial.jl
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ const _ADV_BASE = Dict{String,Any}(
)

_adv_body(overrides::Dict) = merge(_ADV_BASE, overrides)
# The call sites pass pairs, not keyword arguments: `_adv_body("k" => v)` is
# one positional Pair, and without this method it is a MethodError rather than
# a request body.
_adv_body(pairs::Pair...) = merge(_ADV_BASE, Dict{String,Any}(pairs...))
_adv_body(key::String, value) = merge(_ADV_BASE, Dict(key => value))

"""Assert a refused request: documented status, documented code, canonical body."""
Expand Down Expand Up @@ -207,15 +211,37 @@ end
@test response.status != 500
end

# Booleans are not counts, and neither is a JSON float token.
for value in (true, false, 1.5, -1)
# Booleans are not counts. `Any[Any[true, 2], Any[3, 4]]` keeps the
# Boolean in the document: `[[true, 2], [3, 4]]` is an Int array in
# Julia, so it would serialise as 1 and test nothing. A JSON float
# token and a negative integer are refused for the same reason.
for value in (true, false)
body = _adv_body("counts" => Any[Any[value, 2], Any[3, 4]])
status = _adv_post("/api/v1/statistics/summaries", body).status
@test (repr(value), status) == (repr(value), 422)
end
for value in (1.5, -1)
body = _adv_body("counts" => [[value, 2], [3, 4]])
@test _adv_post("/api/v1/statistics/summaries", body).status == 422
status = _adv_post("/api/v1/statistics/summaries", body).status
@test (repr(value), status) == (repr(value), 422)
end
# Counts at 2^53 and above must arrive as whole-number strings.
for bad in (9007199254740992, "-1", "1.5", "1e3")

# A JSON number beyond 2^53-1 is not I-JSON-exact, so the canonical
# reader refuses the document itself (400) before the statistics layer
# sees a count. The same magnitude is accepted when it arrives as a
# whole-number string, which is the documented route for large counts.
huge_number = _adv_post("/api/v1/statistics/summaries",
_adv_body("counts" => [[9007199254740992, 2], [3, 4]])).status
@test huge_number == 400
huge_string = _adv_post("/api/v1/statistics/summaries",
_adv_body("counts" => [["9007199254740993", 2], [3, 4]])).status
@test huge_string == 200
# Non-integral or negative strings are refused by the statistics layer
# (422), not by the JSON reader: they are well-formed JSON.
for bad in ("-1", "1.5", "1e3")
body = _adv_body("counts" => [[bad, 2], [3, 4]])
@test _adv_post("/api/v1/statistics/summaries", body).status == 422
status = _adv_post("/api/v1/statistics/summaries", body).status
@test (repr(bad), status) == (repr(bad), 422)
end
end
end
Expand Down
9 changes: 7 additions & 2 deletions test/unit/test_statistics_properties.jl
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,13 @@ end
@test permuted.samples[j].total == summary.samples[j].total
@test permuted.samples[j].richness == summary.samples[j].richness
if !isnothing(summary.samples[j].shannon)
@test permuted.samples[j].shannon == summary.samples[j].shannon
@test permuted.samples[j].gini_simpson == summary.samples[j].gini_simpson
# Summation order changes with the permutation, so the last bit
# of a 256-bit sum may differ. The metric must agree to within
# the declared tolerance, not bit-for-bit: asserting equality
# here would test the summation order, not the statistic.
@test _close(summary.samples[j].shannon, permuted.samples[j].shannon)
@test _close(summary.samples[j].gini_simpson,
permuted.samples[j].gini_simpson)
end
end
end
Expand Down
16 changes: 16 additions & 0 deletions test/unit/test_statistics_ui.jl
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,22 @@ end
end
end

@testset "The run path is callable without the HTTP handler" begin
# The handler turns any exception into a 500 page, which hides the cause
# behind "No result is claimed". Calling the run path directly means a
# failure reaches the test as the exception it is.
_ui_with_root() do _
params = Dict{String,String}(
"mode" => "high_precision", "precision_bits" => "128",
"sample_labels" => "A, B", "feature_labels" => "t1, t2",
"counts" => "1, 0\n1, 0", "round_digits" => "6")
outcome = _UI_SV._ui_run(params)
@test outcome.result["status"] == "partial"
@test outcome.result["samples"][1]["shannon"]["precision_bits"] == 128
@test isnothing(outcome.result["samples"][2]["shannon"])
end
end

@testset "High-precision form labels approximations as approximate" begin
_ui_with_root() do _
fields = merge(_ui_form(), Dict{String,String}(
Expand Down
Loading