Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
48 changes: 40 additions & 8 deletions faircode/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,13 +210,14 @@ def add(line: str) -> None:
add("-" * WIDTH)
shown = d["groups"][:DISPLAY_GROUPS]
label_w = _label_width(g["label"] for g in shown)
for g in shown:
labels = _clip_labels((g["label"] for g in shown), label_w)
for g, label in zip(shown, labels):
mark = " <- under-represented" if g["label"] in d["under_represented"] else ""
warning = (" ⚠ small group (metric may be unreliable)" if g.get("small_group") else "")
ci = ""
if g.get("ci_low") is not None and g.get("ci_high") is not None:
ci = f" [95% CI {g['ci_low'] * 100:.1f}-{g['ci_high'] * 100:.1f}%]"
add(f" {_clip(g['label'], label_w)} {_bar(g['share'])} "
add(f" {label} {_bar(g['share'])} "
f"{g['share'] * 100:5.1f}% (n={g['count']:,}){ci}{mark}{warning}")
if len(d["groups"]) > DISPLAY_GROUPS:
add(f" … and {len(d['groups']) - DISPLAY_GROUPS} more groups")
Expand All @@ -234,9 +235,11 @@ def add(line: str) -> None:
if d.get("reference"):
ref_groups = d["reference"]["groups"]
add(f" reference (deviation {d['reference']['deviation'] * 100:.1f}%):")
ref_w = _label_width((g["label"] for g in ref_groups[:DISPLAY_GROUPS]), minimum=16, maximum=28)
for g in ref_groups[:DISPLAY_GROUPS]:
add(f" {_clip(g['label'], ref_w)} exp {g['expected'] * 100:5.1f}% "
ref_shown = ref_groups[:DISPLAY_GROUPS]
ref_w = _label_width((g["label"] for g in ref_shown), minimum=16, maximum=28)
ref_labels = _clip_labels((g["label"] for g in ref_shown), ref_w)
for g, label in zip(ref_shown, ref_labels):
add(f" {label} exp {g['expected'] * 100:5.1f}% "
f"act {g['actual'] * 100:5.1f}% "
f"({_strip_neg_zero(g['delta'] * 100):+5.1f} pp)")
if len(ref_groups) > DISPLAY_GROUPS:
Expand Down Expand Up @@ -295,6 +298,33 @@ def _clip(label: str, width: int) -> str:
return (label if len(label) <= width else label[:width - 1] + "…").ljust(width)


def _clip_middle(label: str, width: int) -> str:
"""Like `_clip`, but keep a head and a tail with the ellipsis between them, so two long
labels that share a long prefix stay distinguishable by their ends (#1038)."""
if len(label) <= width:
return label.ljust(width)
keep = width - 1 # one column for the ellipsis
tail = keep // 2
head = keep - tail
return (label[:head] + "…" + label[-tail:]).ljust(width)


def _clip_labels(labels, width) -> list:
"""Clip each label to `width` for display, aligned to the input order. A label that fits is
padded; a long one gets a trailing ellipsis (`_clip`). When two distinct labels would clip to
the same text, the colliding ones are re-clipped with a middle ellipsis (`head…tail`) so they
do not read as a duplicated row (#1038)."""
labels = list(labels)
clipped = [_clip(label, width) for label in labels]
distinct_sources = {}
for label, text in zip(labels, clipped):
distinct_sources.setdefault(text, set()).add(label)
return [
_clip_middle(label, width) if len(distinct_sources[text]) > 1 else text
for label, text in zip(labels, clipped)
]


def _strip_neg_zero(val: float, dp: int = 1) -> float:
"""Return 0.0 when ``val`` rounds to zero at ``dp`` decimals, so a tiny
negative value does not render as a misleading ``-0.0``."""
Expand Down Expand Up @@ -346,11 +376,13 @@ def add(line: str) -> None:
add(f" score {cd['dimension_score_a']} → {cd['dimension_score_b']} "
f"({_delta(cd['dimension_score_delta'])}) TVD {cd['tvd']:.3f}")
add("-" * WIDTH)
label_w = _label_width(g["label"] for g in cd["groups"][:DISPLAY_GROUPS])
for g in cd["groups"][:DISPLAY_GROUPS]:
comp_shown = cd["groups"][:DISPLAY_GROUPS]
label_w = _label_width(g["label"] for g in comp_shown)
comp_labels = _clip_labels((g["label"] for g in comp_shown), label_w)
for g, label in zip(comp_shown, comp_labels):
tag = {"appeared": " (appeared)", "disappeared": " (disappeared)",
"shifted": "", "unchanged": ""}[g["status"]]
add(f" {_clip(g['label'], label_w)} {g['share_a'] * 100:5.1f}% → "
add(f" {label} {g['share_a'] * 100:5.1f}% → "
f"{g['share_b'] * 100:5.1f}% "
f"({_strip_neg_zero(g['share_delta'] * 100):+5.1f} pp){tag}")
if len(cd["groups"]) > DISPLAY_GROUPS:
Expand Down
23 changes: 22 additions & 1 deletion tests/test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,26 @@ def test_to_terminal_widens_for_a_long_group_label_and_keeps_alignment(mock_prof
assert len({line.index("%") for line in rows}) == 1 # every row's percentage lands in the same column


def test_to_terminal_distinguishes_two_long_labels_sharing_a_prefix(mock_profile_result):
# Two labels past the 30-char cap that share their first 29 characters used to clip
# to the same "<29 chars>…" text and read as a duplicated row; the colliding ones now
# get a middle ellipsis so their differing tails stay visible (#1038).
dim = mock_profile_result["dimensions"][0]
dim["under_represented"] = []
dim["groups"] = [
{"label": "x" * 40, "share": 0.5, "count": 600},
{"label": "x" * 40 + "y", "share": 0.5, "count": 600},
]

out = to_terminal(mock_profile_result)

rows = [line for line in out.splitlines() if "(n=" in line][:2]
assert len(rows) == 2
assert rows[0] != rows[1] # the two long labels no longer collapse to one text
assert "…" in rows[0] and "…" in rows[1]
assert ("y" in rows[0]) ^ ("y" in rows[1]) # the distinguishing tail shows on exactly one row


def test_to_terminal_marks_a_cut_reference_group_label(mock_profile_result):
ref_label = "Some Long Reference Group Name That Is Way Past The Cap" # over the 28-char maximum
mock_profile_result["dimensions"][0]["reference"] = {
Expand Down Expand Up @@ -795,7 +815,8 @@ def test_terminal_reports_keep_long_group_labels_apart():
very_long = "x" * 40
clipped = to_terminal(profile(pd.DataFrame({"race": [very_long, very_long + "y"] * 20})))
rows = [line for line in clipped.splitlines() if "(n=" in line]
assert rows and all("x" * 29 + "…" in line and very_long not in line for line in rows) # cut with a marker
assert rows and all("…" in line and very_long not in line for line in rows) # cut with a marker
assert len(rows) == 2 and rows[0] != rows[1] # ...and two long labels sharing a prefix stay distinct (#1038)
ref = profile(df, opts={"reference": {"race": {long_a: 0.5, long_b: 0.5}}})
assert "Hispanic or Latino (any race)" in to_terminal(ref).split("reference (deviation")[1]
drift = compare_to_terminal(compare(result, result, "a.csv", "b.csv"))
Expand Down
Loading