def evidence_tables(left, right, sequences, support, matches, host=None):
"""Return exhaustive support rows and bidirectional counts.
Compatible sequences outside both selected FASTAs are retained and labelled.
Candidate membership and reported PSM membership remain separate.
"""
lp, rp = left["accepted"], right["accepted"]
lc, rc = set(left["aliases"]), set(right["aliases"])
if not (lc | rc) <= set(sequences):
raise ValueError("Search FASTA contains sequences outside declared reference")
if not (lp | rp) <= set(matches):
raise ValueError("Reference mapping omits accepted peptides")
if host is not None and not set(host) <= set(sequences):
raise ValueError("Host reference contains sequences outside declared reference")
retained = lp & rp
gains, losses = rp - lp, lp - rp
unique = {p for p, hashes in matches.items() if len(hashes) == 1}
summary = dict(
peptides_left=len(lp),
peptides_right=len(rp),
peptides_retained=len(lp & rp),
peptides_gained=len(gains),
peptides_lost=len(losses),
right_identity_recovered_by_left=len(lp & rp) / len(rp) if rp else None,
left_identity_retained_by_right=len(lp & rp) / len(lp) if lp else None,
left_candidates=len(lc),
right_candidates=len(rc),
candidate_sequences_added=len(rc - lc),
candidate_sequences_removed=len(lc - rc),
reference_sequences=len(sequences),
)
endpoints = {k: [set(), set()] for k in ("one_peptide", "two_peptides", "two_with_specific")}
proteins = []
for digest in sorted(support):
a, b = support[digest] & lp, support[digest] & rp
if not (a or b):
continue
au, bu = a & unique, b & unique
row = dict(
protein_sha256=digest,
sequence_length=len(sequences[digest]),
in_left_candidates=int(digest in lc),
in_right_candidates=int(digest in rc),
host_sequence="unclassified" if host is None else int(digest in host),
left_peptides=len(a),
right_peptides=len(b),
left_reference_specific_peptides=len(au),
right_reference_specific_peptides=len(bu),
gained_peptides=len(b - a),
lost_peptides=len(a - b),
left_peptide_sequences=";".join(sorted(a)),
right_peptide_sequences=";".join(sorted(b)),
gained_peptide_sequences=";".join(sorted(b - a)),
lost_peptide_sequences=";".join(sorted(a - b)),
reference_specific_peptide_sequences=";".join(sorted(au | bu)),
left_reported_peptides=len(left["support"].get(digest, set())),
right_reported_peptides=len(right["support"].get(digest, set())),
)
for name, condition in (
("one_peptide", (bool(a), bool(b))),
("two_peptides", (len(a) >= 2, len(b) >= 2)),
("two_with_specific", (len(a) >= 2 and bool(au), len(b) >= 2 and bool(bu))),
):
for i, passed in enumerate(condition):
if passed:
endpoints[name][i].add(digest)
row[name + "_change"] = (
"retained"
if all(condition)
else "gained"
if condition[1]
else "lost"
if condition[0]
else "below_threshold"
)
proteins.append(row)
for name, (a, b) in endpoints.items():
for suffix, value in (
("left", len(a)),
("right", len(b)),
("retained", len(a & b)),
("gained", len(b - a)),
("lost", len(a - b)),
):
summary[name + "_" + suffix] = value
summary[name + "_gained_on_added_candidates"] = len((b - a) & (rc - lc))
summary[name + "_gained_on_retained_candidates"] = len((b - a) & lc & rc)
summary[name + "_gained_outside_right_candidates"] = len((b - a) - rc)
peptides = []
for p in sorted(lp | rp):
hashes = matches[p]
category = "retained" if p in retained else "gained" if p in gains else "lost"
if host is None:
source = "unclassified"
else:
source = (
"host_only"
if hashes <= host
else "nonhost_only"
if not hashes & host
else "host_nonhost_ambiguous"
)
peptides.append(
dict(
peptide=p,
change=category,
reference_proteins=len(hashes),
reference_specific=int(len(hashes) == 1),
reference_source=source,
protein_sha256=";".join(sorted(hashes)),
)
)
if host is not None:
for category in ("retained", "gained", "lost"):
for source in ("host_only", "nonhost_only", "host_nonhost_ambiguous"):
summary[f"peptides_{category}_{source}"] = sum(
r["change"] == category and r["reference_source"] == source for r in peptides
)
a, b = left["lfq"], right["lfq"]
shared = sorted(set(a) & set(b))
ratios = [math.log2(b[k] / a[k]) for k in shared]
summary.update(
lfq_left=len(a),
lfq_right=len(b),
lfq_shared=len(shared),
lfq_gained=len(set(b) - set(a)),
lfq_lost=len(set(a) - set(b)),
lfq_ratio=len(b) / len(a) if a else None,
lfq_spearman=correlation(rank([a[k] for k in shared]), rank([b[k] for k in shared])),
lfq_median_log2_ratio=statistics.median(ratios) if ratios else None,
lfq_median_absolute_log2_ratio=statistics.median(map(abs, ratios)) if ratios else None,
)
return summary, proteins, peptides