Skip to content

Commit 0bb3100

Browse files
yilibinbinAaronhaohaoclaude
authored
fix(latex): LaTeX-export audit findings (F5/F13/F14) (#75)
* fix(latex): LaTeX-export audit findings F5/F13/F14 F5 (latex_formatting.py) — uncertainty rounding carry-over understated the displayed uncertainty 10x when a magnitude>=1 value rounded up to the next power of ten at the requested sig figs (9.6@1sf rendered as "1", 99.6@2sf as "10"). The carry branch used unc_int//10 with decimal_places-1; use unc_int=10**target at the same decimal place so 9.6->10, 0.96->1.0, 99.6->100. F13 (latex_tables_statistics_matrix.py) — the covariance/correlation matrix table hardcoded d{12} / S[table-format=1.16] instead of computing per-column specs from cell magnitudes, so large/small/exponent entries overflowed the fixed format. Compute each column's spec via calculate_dcolumn_format_for_column / _siunitx_column_spec like every other table family. F14 (latex_tables_error_propagation.py) — the user-supplied caption went into \caption{...} unescaped, so LaTeX specials (_ % & # $) broke compilation. Escape it via _escape_latex_text (the fallback stays verbatim, it is already LaTeX). TDD: carry-over cases (9.6/99.6/0.96) + non-carry regression guard; matrix siunitx test updated to assert a computed S[table-format=...] spec. * test(latex): assert concrete per-column siunitx specs in statistics-matrix Review follow-up on the F13 per-column siunitx/dcolumn fix. The test only checked that some "S[table-format=" substring existed, which would miss a regression in the per-column formatter (e.g. reverting to a single hardcoded spec). Assert the concrete list of per-column specs produced for this payload instead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: fanghao <fanghaoaa@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent baace01 commit 0bb3100

5 files changed

Lines changed: 84 additions & 8 deletions

datalab_latex/latex_formatting.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -348,10 +348,18 @@ def _uncertainty_decimal_places(unc: mp.mpf, target_digits: int) -> tuple[int, s
348348
decimal_places = max(0, target_digits - 1 - order)
349349
scale = mp.power(10, decimal_places)
350350
unc_int = int(mp.nint(s_abs * scale))
351-
# Handle rare rounding causing carry into extra digit
351+
# Rounding can carry into an extra digit (e.g. 9.6 @ 1 sig fig -> nint(9.6)=10).
352+
# The rounded value then has target_digits+1 digits; drop the least-significant
353+
# one so it keeps exactly target_digits sig figs at one lower decimal place:
354+
# unc_int becomes 10**(target_digits-1) (10, 100, ...) — NOT unc_int/10, which
355+
# would understate the magnitude by 10x (audit F5).
352356
if unc_int >= 10**target_digits:
353-
unc_int = int(mp.nint(s_abs * scale / 10))
354-
decimal_places = max(0, decimal_places - 1)
357+
# Rounding carried into an extra digit (9.6@1sf -> nint=10, 99.6@2sf ->
358+
# nint=100). The correct integer is 10**target_digits at the SAME decimal
359+
# place: its real value is int(unc_int) * 10**-decimal_places, i.e.
360+
# 9.6->10.0, 0.96->1.0, 99.6->100.0. The old code used unc_int//10 and
361+
# decremented decimal_places, understating the uncertainty 10x (audit F5).
362+
unc_int = 10**target_digits
355363
return decimal_places, str(unc_int)
356364

357365

datalab_latex/latex_tables_error_propagation.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,10 @@ def generate_error_propagation_table(
287287
"\\begin{table}[!ht]",
288288
"\t\\centering",
289289
"\t\\caption{{{0}}}\\label{{tab:error_propagation_{1}}}".format(
290-
caption
290+
# Escape the user-supplied caption so LaTeX specials
291+
# (_ % & # $) don't break compilation (audit F14); the
292+
# fallback is already-formatted LaTeX, so leave it verbatim.
293+
_escape_latex_text(caption)
291294
if caption
292295
else "Error propagation results using formula: $" + format_latex_formula(formula_str) + "$",
293296
block_index,

datalab_latex/latex_tables_statistics_matrix.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from pathlib import Path
55
from typing import Any
66

7+
from .latex_formatting import _siunitx_column_spec, calculate_dcolumn_format_for_column
78
from .latex_tables_common import (
89
_build_standalone_preamble,
910
_estimate_page_geometry,
@@ -68,8 +69,26 @@ def _matrix_table_block(
6869
use_dcolumn: bool,
6970
unit: str = "",
7071
) -> list[str]:
71-
numeric_spec = "d{12}" if use_dcolumn else "S[table-format=1.16]"
72-
column_spec = " ".join(["l", *(numeric_spec for _ in columns)])
72+
# Compute each numeric column's spec from its actual cell magnitudes rather
73+
# than hardcoding d{12} / S[table-format=1.16]; a large/small/exponent value
74+
# otherwise overflows the fixed format and mis-renders or fails to compile
75+
# (audit F13). Cells for display column j are values[i][j] across rows i.
76+
values = block["values"]
77+
78+
def _column_cells(col_index: int) -> list[str]:
79+
return [
80+
"" if (col_index >= len(row) or row[col_index] is None) else str(row[col_index])
81+
for row in values
82+
]
83+
84+
per_column_specs: list[str] = []
85+
for col_index in range(len(columns)):
86+
cells = _column_cells(col_index)
87+
if use_dcolumn:
88+
per_column_specs.append(calculate_dcolumn_format_for_column(cells, "statistics_matrix"))
89+
else:
90+
per_column_specs.append(_siunitx_column_spec(cells))
91+
column_spec = " ".join(["l", *per_column_specs])
7392
lines = [
7493
f"\\subsection*{{{latex_escape(kind.capitalize())}}}",
7594
f"\\noindent Unit: \\texttt{{{latex_escape(unit)}}}" if unit else "",
@@ -83,7 +102,6 @@ def _matrix_table_block(
83102
"\\midrule",
84103
]
85104
lines = [line for line in lines if line]
86-
values = block["values"]
87105
for row_column, row in zip(columns, values):
88106
cells = [_numeric_cell(cell) for cell in row]
89107
lines.append(" & ".join([latex_escape(row_column)] + cells) + r" \\")

tests/test_datalab_core_statistics_matrix.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import re
34
from copy import deepcopy
45
from pathlib import Path
56

@@ -235,7 +236,13 @@ def test_statistics_matrix_latex_can_use_siunitx_columns() -> None:
235236
tex = generate_statistics_matrix_latex(payload, use_dcolumn=False, latex_group_size=0)
236237

237238
assert "\\usepackage{dcolumn}" not in tex
238-
assert "S[table-format=1.16]" in tex
239+
# Column spec is now computed per-column from the actual cell magnitudes
240+
# (audit F13). Assert the concrete specs, not just that some S column
241+
# exists, so a regression in the per-column formatter is caught: this 3x2
242+
# payload of small values yields one S[table-format=1.31] spec per numeric
243+
# cell column (integer part width 1, fractional width from the precision).
244+
specs = re.findall(r"S\[table-format=[^\]]*\]", tex)
245+
assert specs == ["S[table-format=1.31]"] * 4
239246

240247

241248
def test_statistics_matrix_latex_handles_pairwise_payload_summary() -> None:
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Uncertainty rounding carry-over (audit finding F5).
2+
3+
When a magnitude>=1 uncertainty rounds UP to the next power of ten at the
4+
requested significant digits, the carry handling must keep the magnitude
5+
(e.g. 9.6 @ 1 sig fig -> "10", not "1"), not shrink it 10x.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from mpmath import mp
11+
12+
from datalab_latex.latex_formatting import _uncertainty_decimal_places
13+
14+
15+
def test_carry_to_next_power_of_ten_keeps_magnitude_one_sig_fig():
16+
# 9.6 to 1 significant digit rounds to 10, not 1.
17+
decimal_places, unc_str = _uncertainty_decimal_places(mp.mpf("9.6"), 1)
18+
assert (decimal_places, unc_str) == (0, "10")
19+
20+
21+
def test_carry_to_next_power_of_ten_keeps_magnitude_two_sig_figs():
22+
# 99.6 to 2 significant digits rounds to 100, not 10.
23+
decimal_places, unc_str = _uncertainty_decimal_places(mp.mpf("99.6"), 2)
24+
assert (decimal_places, unc_str) == (0, "100")
25+
26+
27+
def test_non_carry_cases_unchanged():
28+
# Regression guard: values that do NOT carry stay exactly as before.
29+
assert _uncertainty_decimal_places(mp.mpf("9.6"), 2) == (1, "96")
30+
assert _uncertainty_decimal_places(mp.mpf("1.23"), 1) == (0, "1")
31+
assert _uncertainty_decimal_places(mp.mpf("0.045"), 1) == (2, "4")
32+
33+
34+
def test_carry_with_fractional_uncertainty_keeps_magnitude():
35+
# 0.96 to 1 sig fig carries to 1.0: unc_int "10" at decimal_places 1
36+
# (real value = 10 * 10**-1 = 1.0), NOT "1" at dp 1 (= 0.1, 10x too small).
37+
decimal_places, unc_str = _uncertainty_decimal_places(mp.mpf("0.96"), 1)
38+
assert unc_str == "10"
39+
assert decimal_places == 1
40+
assert int(unc_str) * mp.power(10, -decimal_places) == mp.mpf("1")

0 commit comments

Comments
 (0)