diff --git a/history/infrastructure/causal-graph-connectivity-metric/2026-08-08T044057Z-claude-code-ef6836.yaml b/history/infrastructure/causal-graph-connectivity-metric/2026-08-08T044057Z-claude-code-ef6836.yaml new file mode 100644 index 00000000..73570950 --- /dev/null +++ b/history/infrastructure/causal-graph-connectivity-metric/2026-08-08T044057Z-claude-code-ef6836.yaml @@ -0,0 +1,41 @@ +history_version: 1 +target: + kind: infrastructure + path: scripts/audit_causal_graphs.py + slug: causal-graph-connectivity-metric +session: + id: 2026-08-08T044057Z-claude-code-ef6836 + timestamp: '2026-08-08T04:40:57Z' + actors: + - type: ai_agent + name: claude-code +links: + issues: + - https://github.com/CultureBotAI/TraitMech/issues/359 +events: +- type: EDIT + outcome: changed + sections: + - causal_graphs + summary: Measure component structure, the connectivity number retyping cannot move + details: 'Issue 359 observed that UNREACHABLE_FROM_TRAIT falls when a node is retyped into + a TRAIT anchor inside an existing island, without the graph becoming more connected. Neither + headline count can tell that apart from a real gain: FRAGMENTED_GRAPH reports one finding + per split graph however many pieces it is in, so 3 components to 2 does not move it either. + Added connectivity_rows(), emitting reports/causal_graph_connectivity.tsv with one row + per graph carrying wired_nodes, components, largest_component and the component sizes, + plus a corpus summary line on the audit. Measured on the worked example from 352, oxygen_preference.yaml: + main and the RETYPED version are bit-for-bit identical at components=3 largest=8 attached=57.1 + percent, while the MERGED version is components=2 largest=11 attached=84.6 percent - and + UNREACHABLE_FROM_TRAIT is 1296 for both fixes, which is exactly the blindness 359 describes. + Corpus baseline: 353 graphs, 864 components over 4137 wired nodes, attached 69.5 percent. + The metric is anchor-free for the same reason _components is, so no amount of retyping + or renaming moves it. Scoped to edge-referenced nodes to match FRAGMENTED_GRAPH, since + an unwired node is ORPHAN_NODE''s business and counting it here would let one defect depress + two metrics. Topology extraction is now a shared _topology() helper used by both audit() + and the metric so the ratchet and the measurement cannot drift on what connected means; + verified behaviour-preserving by diffing regenerated causal_graph_audit.tsv against the + committed copy - identical. Carries no ratchet of its own: it is a measurement, not a + verdict, and never affects the exit code. Wired into audit-derived-reports for staleness, + passing --connectivity-out to the temp dir, because omitting it would have made a staleness + check write the file it was judging. 524 tests pass (5 new), qc green.' diff --git a/justfile b/justfile index 8a6b5ff3..65a4a10c 100644 --- a/justfile +++ b/justfile @@ -584,7 +584,13 @@ audit-derived-reports: # more fragmented" with "the committed report is out of date". So the status # is deliberately ignored and only missing output is fatal. cga=causal_graph_audit.tsv + cgc=causal_graph_connectivity.tsv + # --connectivity-out is NOT optional here. Omitting it would let this + # check write the connectivity report to its default path in the working + # tree -- a staleness check that mutates the file it is judging, which is + # the failure this recipe's header warns about. uv run python scripts/audit_causal_graphs.py --out "$tmp/$cga" \ + --connectivity-out "$tmp/$cgc" \ > "$tmp/gen.log" 2>&1 || true if [ ! -s "$tmp/$cga" ]; then echo "ERROR: audit_causal_graphs.py produced no report. Its output:" >&2 @@ -615,6 +621,31 @@ audit-derived-reports: fail=1 fi + # --- causal_graph_connectivity.tsv, compared against git (#359) ---------- + # Written by the same generator invocation above, so it needs no second + # run. It carries no ratchet of its own: it is a MEASUREMENT of component + # structure, and the whole point of #359 is that a number which cannot be + # gamed by retyping is worth having even when nothing gates on it. Staleness + # still matters -- an out-of-date copy would misreport whether a PR actually + # connected anything, which is the one question it exists to answer. + if [ ! -s "$tmp/$cgc" ]; then + echo "ERROR: audit_causal_graphs.py produced no connectivity report. Its output:" >&2 + cat "$tmp/gen.log" >&2 + exit 1 + fi + if ! git show "HEAD:reports/$cgc" > "$tmp/committed_$cgc" 2>/dev/null; then + echo " MISSING reports/$cgc is not in git at HEAD" >&2 + stale_cga=1 + fail=1 + elif diff -q "$tmp/committed_$cgc" "$tmp/$cgc" >/dev/null; then + echo " OK reports/$cgc (vs git)" + else + echo " STALE reports/$cgc — the COMMITTED copy is not what audit-graphs produces:" >&2 + { diff -u "$tmp/committed_$cgc" "$tmp/$cgc" | sed -n '1,20p' >&2; } || true + stale_cga=1 + fail=1 + fi + # --- predicate_domain_audit.tsv, compared against git (#301) ------------- # Same shape as causal_graph_audit.tsv above: this generator's exit code is # its RATCHET VERDICT (--fail-on new), owned by `audit-predicate-domains` diff --git a/reports/causal_graph_connectivity.tsv b/reports/causal_graph_connectivity.tsv new file mode 100644 index 00000000..9173110b --- /dev/null +++ b/reports/causal_graph_connectivity.tsv @@ -0,0 +1,354 @@ +file graph_id wired_nodes components largest_component component_sizes +data/traits/ecology/animal_pathogen.yaml animal_pathogen_metazoan_adaptation 17 2 15 15,2 +data/traits/ecology/biofilm_formation.yaml biofilm_eps_matrix_community 13 3 9 9,2,2 +data/traits/ecology/biosafety_level.yaml biosafety_level_hazard_classification 15 2 13 13,2 +data/traits/ecology/biosafety_level_1.yaml biosafety_level_1_minimal_hazard 10 2 8 8,2 +data/traits/ecology/biosafety_level_2.yaml biosafety_level_2_moderate_hazard 10 2 8 8,2 +data/traits/ecology/biosafety_level_3.yaml biosafety_level_3_serious_hazard 10 2 8 8,2 +data/traits/ecology/biosafety_level_4.yaml biosafety_level_4_extreme_hazard 10 1 10 10 +data/traits/ecology/biosafety_level_5.yaml biosafety_level_5_proposed_enhanced_hazard 3 1 3 3 +data/traits/ecology/commensalism.yaml commensalism_neutral_host 11 1 11 11 +data/traits/ecology/endosymbiosis.yaml endosymbiosis_intracellular_genome_reduction 10 3 6 6,2,2 +data/traits/ecology/free_living.yaml free_living_environmental_habitat 9 2 7 7,2 +data/traits/ecology/gut_associated.yaml gut_associated_microbiota_metabolism 12 3 8 8,2,2 +data/traits/ecology/habitat_association.yaml habitat_association_biogeographic_structure 10 2 6 6,4 +data/traits/ecology/host_associated.yaml host_associated_microbiome 11 1 11 11 +data/traits/ecology/human_pathogen.yaml human_pathogen_anthropoid_adaptation 13 1 13 13 +data/traits/ecology/mutualism.yaml mutualism_reciprocal_benefit 10 3 6 6,2,2 +data/traits/ecology/nitrogen_fixing_symbiosis.yaml rhizobia_legume_n2_fixation 11 1 11 11 +data/traits/ecology/opportunistic_pathogen.yaml opportunistic_pathogen_context_dependent_virulence 10 1 10 10 +data/traits/ecology/parasitism.yaml parasitism_host_fitness_cost 12 1 12 12 +data/traits/ecology/pathogenic_to_host.yaml pathogenic_to_host_virulence_factor_program 14 2 12 12,2 +data/traits/ecology/plant_pathogen.yaml plant_pathogen_t3ss_effector_program 13 2 10 10,3 +data/traits/ecology/predatory_bacterium.yaml predatory_bacterium_prey_killing 10 4 3 3,3,2,2 +data/traits/ecology/rhizosphere_association.yaml rhizosphere_root_exudate 9 2 6 6,3 +data/traits/ecology/saprotrophy.yaml saprotrophy_decomposition_cycling 14 5 4 4,3,3,2,2 +data/traits/ecology/soil_dwelling.yaml soil_dwelling_biogeochemistry 14 6 3 3,3,2,2,2,2 +data/traits/ecology/symbiosis.yaml symbiosis_host_interaction 13 2 7 7,6 +data/traits/environment/acidophilic.yaml acidophilic_ph_homeostasis 13 3 9 9,2,2 +data/traits/environment/acidotolerant.yaml acidotolerant_acid_stress_homeostasis 9 1 9 9 +data/traits/environment/aerobic.yaml aerobic_trait_mechanism 11 1 11 11 +data/traits/environment/aerotolerant.yaml aerotolerant_anaerobe_ros_defense 11 3 5 5,4,2 +data/traits/environment/alkalotolerant.yaml alkalotolerant_alkaline_stress_homeostasis 11 2 9 9,2 +data/traits/environment/alkaphilic.yaml alkaliphilic_na_cycle_homeostasis 11 2 8 8,3 +data/traits/environment/anaerobic.yaml anaerobic_trait_oxygen_exclusion 10 1 10 10 +data/traits/environment/arsenic_tolerant.yaml arsenic_tolerance_ars_efflux 13 1 13 13 +data/traits/environment/cadmium_tolerant.yaml cadmium_tolerance_czc_efflux 8 1 8 8 +data/traits/environment/cobalt_tolerant.yaml cobalt_tolerance_czc_cnr_efflux 11 1 11 11 +data/traits/environment/copper_tolerant.yaml copper_tolerance_cop_efflux 7 1 7 7 +data/traits/environment/delta_phenotype_with_numerical_limits.yaml delta_phenotype_breadth_descriptor 10 1 10 10 +data/traits/environment/desiccation_tolerant.yaml desiccation_anhydrobiosis_repair 13 3 7 7,4,2 +data/traits/environment/euryhaline.yaml euryhaline_wide_salinity_tolerance 17 3 13 13,2,2 +data/traits/environment/extreme_hyperthermophilic.yaml extreme_hyperthermophilic_archaeal_adaptation 11 2 7 7,4 +data/traits/environment/extremely_halophilic.yaml extreme_halophile_salt_in_acidic_proteome 11 2 9 9,2 +data/traits/environment/facultative_oxygen_preference.yaml facultative_oxygen_preference_switching 9 1 9 9 +data/traits/environment/facultative_psychrophilic.yaml facultative_psychrophilic_cold_tolerance 13 2 11 11,2 +data/traits/environment/facultatively_acidophilic.yaml facultatively_acidophilic_ph_homeostasis 15 2 13 13,2 +data/traits/environment/facultatively_aerobic.yaml facultative_aerobe_oxygen_flexible_growth 11 3 7 7,2,2 +data/traits/environment/facultatively_alkaphilic.yaml facultatively_alkaphilic_sodium_cycle_homeostasis 11 3 5 5,4,2 +data/traits/environment/facultatively_anaerobic.yaml facultative_anaerobe_oxygen_switch 8 1 8 8 +data/traits/environment/growth_range_phenotype_with_numerical_limits.yaml growth_range_phenotype_descriptor 15 3 11 11,2,2 +data/traits/environment/haloalkaliphilic.yaml haloalkaliphilic_salt_alkaline_adaptation 13 2 11 11,2 +data/traits/environment/halophilic.yaml halophilic_osmoadaptation 15 3 11 11,2,2 +data/traits/environment/halophily_preference.yaml halophily_preference_osmoadaptation 14 1 14 14 +data/traits/environment/halotolerant.yaml halotolerant_salt_stress_response 12 1 12 12 +data/traits/environment/hyperthermophilic.yaml hyperthermophilic_thermostability 13 2 10 10,3 +data/traits/environment/ionizing_radiation_tolerant.yaml ionizing_radiation_tolerance_dsb_repair 6 1 6 6 +data/traits/environment/mercury_tolerant.yaml mercury_tolerance_mer_reduction 9 1 9 9 +data/traits/environment/mesophilic.yaml mesophilic_homoviscous_adaptation 15 5 4 4,4,3,2,2 +data/traits/environment/metal_tolerant.yaml metal_tolerance_efflux_detoxification 9 2 6 6,3 +data/traits/environment/microaerophilic.yaml microaerophile_low_oxygen_respiration 14 4 7 7,3,2,2 +data/traits/environment/microaerotolerant.yaml microaerotolerant_low_oxygen_defense 13 3 7 7,4,2 +data/traits/environment/moderately_halophilic.yaml moderate_halophile_compatible_solutes 11 1 11 11 +data/traits/environment/nacl_delta.yaml nacl_delta_euryhaline_breadth 13 4 5 5,4,2,2 +data/traits/environment/nacl_delta_high.yaml nacl_delta_high_extreme_euryhaline 14 6 3 3,3,2,2,2,2 +data/traits/environment/nacl_delta_low.yaml nacl_delta_low_stenohaline 8 1 8 8 +data/traits/environment/nacl_delta_mid1.yaml nacl_delta_mid1_modest_breadth 11 2 8 8,3 +data/traits/environment/nacl_delta_mid2.yaml nacl_delta_mid2_broad_breadth 13 5 3 3,3,3,2,2 +data/traits/environment/nacl_optimum.yaml nacl_optimum_balanced_osmoadaptation 16 5 8 8,2,2,2,2 +data/traits/environment/nacl_optimum_high.yaml nacl_optimum_high_extreme_halophile 11 2 9 9,2 +data/traits/environment/nacl_optimum_low.yaml nacl_optimum_low_non_halophile_setpoint 11 2 8 8,3 +data/traits/environment/nacl_optimum_mid1.yaml nacl_optimum_mid1_slight_halophile 10 2 7 7,3 +data/traits/environment/nacl_optimum_mid2.yaml nacl_optimum_mid2_moderate_halophile 9 1 9 9 +data/traits/environment/nacl_range.yaml nacl_range_tolerance_breadth 10 1 10 10 +data/traits/environment/nacl_range_high.yaml nacl_range_high_extreme_halophile 10 3 6 6,2,2 +data/traits/environment/nacl_range_low.yaml nacl_range_low_non_halophile 12 5 3 3,3,2,2,2 +data/traits/environment/nacl_range_mid1.yaml nacl_range_mid1_slight_halophile 10 3 6 6,2,2 +data/traits/environment/nacl_range_mid2.yaml nacl_range_mid2_moderate_halophile 10 1 10 10 +data/traits/environment/neutrophilic.yaml neutrophilic_neutral_ph_homeostasis 11 3 7 7,2,2 +data/traits/environment/non_halophilic.yaml non_halophilic_salt_stress_response 12 3 5 5,5,2 +data/traits/environment/obligately_acidophilic.yaml obligately_acidophilic_ph_homeostasis 14 1 14 14 +data/traits/environment/obligately_aerobic.yaml obligate_aerobe_oxygen_respiration 12 3 7 7,3,2 +data/traits/environment/obligately_alkaphilic.yaml obligately_alkaphilic_sodium_cycle_homeostasis 14 2 9 9,5 +data/traits/environment/obligately_anaerobic.yaml obligate_anaerobe_oxygen_toxicity 14 2 10 10,4 +data/traits/environment/obligately_piezophilic.yaml obligate_piezophily_high_pressure_requirement 6 1 6 6 +data/traits/environment/optimum_phenotype_with_numerical_limits.yaml optimum_phenotype_descriptor 14 4 5 5,5,2,2 +data/traits/environment/oxygen_preference.yaml oxygen_preference_o2_availability_axis 13 2 11 11,2 +data/traits/environment/ph_delta.yaml ph_delta_homeostasis_flexibility 11 2 9 9,2 +data/traits/environment/ph_delta_high.yaml ph_delta_high_euryphilic_breadth 14 4 7 7,3,2,2 +data/traits/environment/ph_delta_low.yaml ph_delta_low_limited_breadth 11 4 4 4,3,2,2 +data/traits/environment/ph_delta_mid1.yaml ph_delta_mid1_moderate_breadth 13 4 7 7,2,2,2 +data/traits/environment/ph_delta_mid2.yaml ph_delta_mid2_broad_breadth 15 7 3 3,2,2,2,2,2,2 +data/traits/environment/ph_delta_mid3.yaml ph_delta_mid3_wide_breadth 15 6 3 3,3,3,2,2,2 +data/traits/environment/ph_delta_very_low.yaml ph_delta_very_low_stenotopic 13 6 3 3,2,2,2,2,2 +data/traits/environment/ph_growth_preference.yaml ph_growth_preference_homeostasis 12 1 12 12 +data/traits/environment/ph_optimum.yaml ph_optimum_balanced_homeostasis 12 2 10 10,2 +data/traits/environment/ph_optimum_high.yaml ph_optimum_high_alkaliphile_setpoint 15 5 6 6,3,2,2,2 +data/traits/environment/ph_optimum_low.yaml ph_optimum_low_acidophile_setpoint 12 5 4 4,2,2,2,2 +data/traits/environment/ph_optimum_mid1.yaml ph_optimum_mid1_neutrophile_setpoint 10 1 10 10 +data/traits/environment/ph_optimum_mid2.yaml ph_optimum_mid2_alkaline_tolerant_setpoint 12 3 5 5,4,3 +data/traits/environment/ph_phenotype_with_numerical_limits.yaml ph_phenotype_numerical_axis 15 5 6 6,3,2,2,2 +data/traits/environment/ph_range.yaml ph_range_bounded_homeostasis 11 1 11 11 +data/traits/environment/ph_range_high.yaml ph_range_high_extreme_alkaliphile 12 2 10 10,2 +data/traits/environment/ph_range_low.yaml ph_range_low_acidophile_range 13 5 5 5,2,2,2,2 +data/traits/environment/ph_range_mid1.yaml ph_range_mid1_neutrophile_range 9 1 9 9 +data/traits/environment/ph_range_mid2.yaml ph_range_mid2_mild_alkaline_tolerance 15 6 3 3,3,3,2,2,2 +data/traits/environment/ph_range_mid3.yaml ph_range_mid3_alkaliphile_range 11 2 6 6,5 +data/traits/environment/ph_range_very_low.yaml ph_range_very_low_extreme_acidophile 11 4 3 3,3,3,2 +data/traits/environment/piezophilic.yaml piezophilic_hhp_membrane_adaptation 8 1 8 8 +data/traits/environment/piezotolerant.yaml piezotolerance_pressure_range 9 1 9 9 +data/traits/environment/pressure_delta.yaml pressure_delta_breadth_descriptor 12 4 6 6,2,2,2 +data/traits/environment/pressure_optimum.yaml pressure_optimum_balanced_adaptation 9 2 5 5,4 +data/traits/environment/pressure_range.yaml pressure_range_growth_bounded_span 9 2 6 6,3 +data/traits/environment/psychrophilic.yaml psychrophilic_cold_adaptation 13 2 10 10,3 +data/traits/environment/psychrotolerant.yaml psychrotolerant_facultative_cold_adaptation 11 3 7 7,2,2 +data/traits/environment/radiotolerant.yaml radiotolerance_repair_antioxidant 11 1 11 11 +data/traits/environment/salinity_phenotype_with_numerical_limits.yaml salinity_phenotype_numerical_axis 13 2 9 9,4 +data/traits/environment/slightly_halophilic.yaml slight_halophile_low_salt_osmoadaptation 10 2 8 8,2 +data/traits/environment/stenohaline.yaml stenohaline_narrow_salinity_tolerance 17 6 6 6,3,2,2,2,2 +data/traits/environment/strictly_anaerobic.yaml strict_anaerobe_oxygen_sensitivity 8 1 8 8 +data/traits/environment/temperature_delta.yaml temperature_delta_thermal_flexibility 7 1 7 7 +data/traits/environment/temperature_delta_high.yaml temperature_delta_high_eurythermal 15 6 3 3,3,3,2,2,2 +data/traits/environment/temperature_delta_low.yaml temperature_delta_low_limited_breadth 8 1 8 8 +data/traits/environment/temperature_delta_mid1.yaml temperature_delta_mid1_moderate_breadth 11 2 9 9,2 +data/traits/environment/temperature_delta_mid2.yaml temperature_delta_mid2_broad_breadth 10 2 6 6,4 +data/traits/environment/temperature_delta_very_low.yaml temperature_delta_very_low_stenothermal 9 4 3 3,2,2,2 +data/traits/environment/temperature_optimum.yaml temperature_optimum_balanced_adaptation 9 2 7 7,2 +data/traits/environment/temperature_optimum_high.yaml temperature_optimum_high_thermophile_setpoint 16 6 5 5,3,2,2,2,2 +data/traits/environment/temperature_optimum_low.yaml temperature_optimum_low_psychrotolerant_setpoint 14 4 5 5,4,3,2 +data/traits/environment/temperature_optimum_mid1.yaml temperature_optimum_mid1_lower_mesophile 10 1 10 10 +data/traits/environment/temperature_optimum_mid2.yaml temperature_optimum_mid2_mesophile 9 2 7 7,2 +data/traits/environment/temperature_optimum_mid3.yaml temperature_optimum_mid3_upper_mesophile 12 5 4 4,2,2,2,2 +data/traits/environment/temperature_optimum_mid4.yaml temperature_optimum_mid4_warm_mesophile 15 4 4 4,4,4,3 +data/traits/environment/temperature_optimum_very_low.yaml temperature_optimum_very_low_psychrophile_setpoint 17 6 7 7,2,2,2,2,2 +data/traits/environment/temperature_phenotype_with_numerical_limits.yaml temperature_phenotype_numerical_axis 13 4 5 5,4,2,2 +data/traits/environment/temperature_preference.yaml temperature_preference_growth_physiology 13 2 11 11,2 +data/traits/environment/temperature_range.yaml temperature_range_bounded_adaptation 11 1 11 11 +data/traits/environment/temperature_range_high.yaml temperature_range_high_thermophile 10 1 10 10 +data/traits/environment/temperature_range_low.yaml temperature_range_low_psychrotolerant 13 5 4 4,3,2,2,2 +data/traits/environment/temperature_range_mid1.yaml temperature_range_mid1_lower_mesophile 13 5 4 4,3,2,2,2 +data/traits/environment/temperature_range_mid2.yaml temperature_range_mid2_baseline_mesophile 13 3 8 8,3,2 +data/traits/environment/temperature_range_mid3.yaml temperature_range_mid3_upper_mesophile 14 6 3 3,3,2,2,2,2 +data/traits/environment/temperature_range_mid4.yaml temperature_range_mid4_warm_mesophile 17 6 5 5,3,3,2,2,2 +data/traits/environment/temperature_range_very_low.yaml temperature_range_very_low_psychrophile 15 7 3 3,2,2,2,2,2,2 +data/traits/environment/thermophilic.yaml thermophilic_heat_adaptation 14 4 6 6,4,2,2 +data/traits/environment/thermotolerant.yaml thermotolerant_facultative_heat_adaptation 11 2 6 6,5 +data/traits/environment/uv_radiation_tolerant.yaml uv_tolerance_excision_repair 10 1 10 10 +data/traits/environment/xerophilic.yaml xerophilic_low_water_activity_growth 8 2 6 6,2 +data/traits/environment/zinc_tolerant.yaml zinc_tolerance_czc_efflux 11 1 11 11 +data/traits/genomics/codon_usage_bias.yaml codon_bias_translation_efficiency 6 1 6 6 +data/traits/genomics/crispr_cas_system.yaml crispr_adaptive_immunity 10 3 4 4,4,2 +data/traits/genomics/gc_content.yaml gc_content_composition_drivers 13 1 13 13 +data/traits/genomics/gc_high.yaml gc_high_low_gc_bin 10 1 10 10 +data/traits/genomics/gc_low.yaml gc_low_mid_low_gc_bin 8 2 6 6,2 +data/traits/genomics/gc_mid1.yaml gc_mid1_high_gc_bin 7 1 7 7 +data/traits/genomics/gc_mid2.yaml gc_mid2_mid_high_gc_bin 9 1 9 9 +data/traits/genomics/gc_skew.yaml gc_skew_replication_strand_asymmetry 9 1 9 9 +data/traits/genomics/genome_size.yaml genome_size_population_lifestyle 9 1 9 9 +data/traits/genomics/genome_streamlining.yaml streamlining_oligotrophic_selection 9 2 7 7,2 +data/traits/genomics/genomic_island.yaml gi_hgt_accessory_function 8 2 4 4,4 +data/traits/genomics/mobile_genetic_element.yaml mge_horizontal_gene_transfer 16 4 10 10,2,2,2 +data/traits/genomics/pangenome_openness.yaml pangenome_openness_hgt 11 2 8 8,3 +data/traits/genomics/plasmid_carriage.yaml plasmid_conjugation_hgt 11 3 6 6,3,2 +data/traits/genomics/ploidy.yaml ploidy_repair_survival 9 2 5 5,4 +data/traits/genomics/prophage.yaml prophage_lysogeny 13 4 5 5,4,2,2 +data/traits/genomics/restriction_modification_system.yaml rm_self_nonself_defense 11 2 9 9,2 +data/traits/genomics/rrna_operon_copy_number.yaml rrn_copy_growth_rate 7 1 7 7 +data/traits/genomics/transposable_element.yaml te_transposition_rearrangement 8 2 5 5,3 +data/traits/metabolism/acetogenesis.yaml acetogenesis_wood_ljungdahl 14 1 14 14 +data/traits/metabolism/aerobic_respiration.yaml aerobic_respiration_terminal_oxidase 8 2 5 5,3 +data/traits/metabolism/anaerobic_oxidation_of_methane.yaml aom_anme_sulfate_consortium 8 1 8 8 +data/traits/metabolism/anaerobic_respiration.yaml anaerobic_respiration_denitrification 14 4 6 6,3,3,2 +data/traits/metabolism/anoxygenic_photosynthesis.yaml anoxygenic_photosynthesis_sulfide_donor 7 1 7 7 +data/traits/metabolism/biopolymer_degradation.yaml biopolymer_degradation_extracellular_hydrolysis 14 6 3 3,3,2,2,2,2 +data/traits/metabolism/cable_bacteria_metabolism.yaml cable_bacteria_long_distance_electron_transport 14 1 14 14 +data/traits/metabolism/calvin_benson_bassham_cycle.yaml cbb_rubisco_co2_fixation 13 3 9 9,2,2 +data/traits/metabolism/carbon_fixation.yaml carbon_fixation_co2_assimilation 15 4 6 6,4,3,2 +data/traits/metabolism/cellulolysis.yaml cellulolysis_cellulase_systems 14 1 14 14 +data/traits/metabolism/chitinolysis.yaml chitinolysis_chitinase 9 3 5 5,2,2 +data/traits/metabolism/denitrification.yaml denitrification_stepwise_nitrate_to_n2 15 6 5 5,2,2,2,2,2 +data/traits/metabolism/dicarboxylate_four_hydroxybutyrate_cycle.yaml dc_four_hb_anaerobic_archaeal_co2_fixation 14 2 10 10,4 +data/traits/metabolism/disproportionation.yaml sulfur_disproportionation_redox_split 14 1 14 14 +data/traits/metabolism/dissimilatory_iron_reduction.yaml dir_ferric_iron_respiration 8 1 8 8 +data/traits/metabolism/dissimilatory_manganese_reduction.yaml dmr_mn_oxide_respiration 7 1 7 7 +data/traits/metabolism/dissimilatory_metal_reduction.yaml metal_reduction_anaerobic_respiration 10 3 4 4,3,3 +data/traits/metabolism/dissimilatory_nitrate_reduction_to_ammonium.yaml dnra_nitrate_to_ammonium 11 3 5 5,3,3 +data/traits/metabolism/dissimilatory_sulfate_reduction.yaml sulfate_reduction_to_sulfide 12 2 10 10,2 +data/traits/metabolism/electron_transfer.yaml electron_transfer_redox_carriers 17 4 7 7,5,3,2 +data/traits/metabolism/ethanol_fermentation.yaml ethanol_fermentation_pyruvate_to_ethanol 10 1 10 10 +data/traits/metabolism/fermentation.yaml fermentation_redox_energy 16 4 9 9,3,2,2 +data/traits/metabolism/fermentative_hydrogen_production.yaml fermentative_h2_production 7 1 7 7 +data/traits/metabolism/homoacetogenesis.yaml homoacetogenesis_wood_ljungdahl_acetate 21 2 19 19,2 +data/traits/metabolism/iron_oxidation.yaml iron_oxidation_ferrous_to_ferric 12 5 4 4,2,2,2,2 +data/traits/metabolism/lactic_acid_fermentation.yaml lactic_acid_fermentation_lactate 12 1 12 12 +data/traits/metabolism/lignin_degradation.yaml lignin_degradation_peroxidase_laccase 13 4 5 5,3,3,2 +data/traits/metabolism/manganese_oxidation.yaml manganese_oxidation_multicopper_oxidase 7 1 7 7 +data/traits/metabolism/metabolism.yaml metabolism_substrate_to_growth 14 4 6 6,4,2,2 +data/traits/metabolism/methanogenesis.yaml methanogenesis_c1_reduction 15 4 8 8,3,2,2 +data/traits/metabolism/mixed_acid_fermentation.yaml mixed_acid_fermentation_enterobacterial 15 2 13 13,2 +data/traits/metabolism/nitrogen_fixation.yaml nitrogen_fixation_nitrogenase 11 1 11 11 +data/traits/metabolism/oxidative_phosphorylation.yaml oxidative_phosphorylation_chemiosmotic_coupling 16 4 7 7,5,2,2 +data/traits/metabolism/oxygenic_photosynthesis.yaml oxygenic_photosynthesis_water_splitting 13 3 7 7,4,2 +data/traits/metabolism/photosynthesis.yaml photosynthesis_chlorophyll_reaction_center 10 2 6 6,4 +data/traits/metabolism/phototrophy.yaml phototrophy_light_energy_capture 11 2 6 6,5 +data/traits/metabolism/propionic_acid_fermentation.yaml propionic_acid_fermentation_propionate 15 4 6 6,5,2,2 +data/traits/metabolism/proteolysis.yaml proteolysis_extracellular_protease 8 1 8 8 +data/traits/metabolism/proteorhodopsin_phototrophy.yaml proteorhodopsin_light_driven_proton_pump 8 1 8 8 +data/traits/metabolism/reductive_tca_cycle.yaml rtca_reverse_tricarboxylic_acid_co2_fixation 8 1 8 8 +data/traits/metabolism/respiration.yaml respiration_electron_acceptor_energy_conservation 13 2 8 8,5 +data/traits/metabolism/starch_degradation.yaml starch_degradation_amylase 6 1 6 6 +data/traits/metabolism/substrate_level_phosphorylation.yaml substrate_level_phosphorylation_direct_atp 13 3 6 6,5,2 +data/traits/metabolism/sulfur_oxidation.yaml sulfur_oxidation_sox 13 1 13 13 +data/traits/metabolism/syntrophy.yaml syntrophy_interspecies_electron_transfer 16 2 12 12,4 +data/traits/metabolism/three_hydroxypropionate_bicycle.yaml three_hp_bicycle_chloroflexus 14 5 6 6,2,2,2,2 +data/traits/metabolism/three_hydroxypropionate_four_hydroxybutyrate_cycle.yaml three_hp_four_hb_sulfolobales 11 1 11 11 +data/traits/metabolism/wood_ljungdahl_pathway.yaml wood_ljungdahl_reductive_acetyl_coa 15 4 9 9,2,2,2 +data/traits/metabolism/xylan_degradation.yaml xylan_degradation_xylanase 8 3 3 3,3,2 +data/traits/morphology/amphitrichous.yaml amphitrichous_bipolar_flagella 11 2 8 8,3 +data/traits/morphology/axially_filamented.yaml axially_filamented_periplasmic_flagella 10 1 10 10 +data/traits/morphology/bacillus_shaped.yaml bacillus_shaped_rod_elongation 14 4 8 8,2,2,2 +data/traits/morphology/black_pigmented.yaml black_pigmented_melanin_polymer 18 6 6 6,3,3,2,2,2 +data/traits/morphology/branched_shaped.yaml branched_shaped_streptomyces_branching 15 4 7 7,4,2,2 +data/traits/morphology/brown_pigmented.yaml brown_pigmented_pyomelanin_pathway 10 2 8 8,2 +data/traits/morphology/capsule.yaml capsule_polysaccharide_protection 11 3 7 7,2,2 +data/traits/morphology/carboxysome.yaml carboxysome_co2_concentrating 10 1 10 10 +data/traits/morphology/carotenoid_pigmentation.yaml carotenoid_pigmentation_crt_pathway 12 1 12 12 +data/traits/morphology/cell_length.yaml cell_length_division_growth_control 12 1 12 12 +data/traits/morphology/cell_length_large.yaml cell_length_large_division_delay 10 4 4 4,2,2,2 +data/traits/morphology/cell_length_medium.yaml cell_length_medium_growth_rate 13 5 4 4,3,2,2,2 +data/traits/morphology/cell_length_small.yaml cell_length_small_size_setpoint 15 6 5 5,2,2,2,2,2 +data/traits/morphology/cell_length_very_small.yaml cell_length_very_small_streamlining 9 2 7 7,2 +data/traits/morphology/cell_shape.yaml cell_shape_peptidoglycan_cytoskeleton 14 2 12 12,2 +data/traits/morphology/cell_width.yaml cell_width_mreb_lateral_wall 9 1 9 9 +data/traits/morphology/cell_width_large.yaml cell_width_large_setpoint_increase 15 5 6 6,3,2,2,2 +data/traits/morphology/cell_width_medium.yaml cell_width_medium_typical_rod 10 1 10 10 +data/traits/morphology/cell_width_small.yaml cell_width_small_mreb_setpoint 10 2 8 8,2 +data/traits/morphology/cell_width_very_small.yaml cell_width_very_small_streamlining 11 3 7 7,2,2 +data/traits/morphology/coccobacillus_shaped.yaml coccobacillus_shaped_short_rod_morphogenesis 11 1 11 11 +data/traits/morphology/coccus_shaped.yaml coccus_shaped_septal_growth 9 1 9 9 +data/traits/morphology/cream_pigmented.yaml cream_pigmented_low_carotenoid_density 7 1 7 7 +data/traits/morphology/crescent_shaped.yaml crescent_shaped_crescentin_curvature 8 1 8 8 +data/traits/morphology/curved_shaped.yaml curved_shaped_scaffolded_curvature 9 1 9 9 +data/traits/morphology/diplococcus_shaped.yaml diplococcus_shaped_septal_separation 11 1 11 11 +data/traits/morphology/disc_shaped.yaml disc_shaped_anisotropic_wall_growth 6 1 6 6 +data/traits/morphology/dumbbell_shaped.yaml dumbbell_shaped_snapping_division 11 2 7 7,4 +data/traits/morphology/ellipsoidal.yaml ellipsoidal_ovococcal_elongation 13 2 10 10,3 +data/traits/morphology/filament_shaped.yaml filament_shaped_streptomyces_polar_growth 13 1 13 13 +data/traits/morphology/flagellar_arrangement.yaml flagellar_arrangement_flhf_flhg 9 2 6 6,3 +data/traits/morphology/flagellated.yaml flagellated_flagellar_motor 16 3 10 10,3,3 +data/traits/morphology/flask_shaped.yaml flask_shaped_asymmetric_polar_growth 12 5 4 4,2,2,2,2 +data/traits/morphology/fusiform_shaped.yaml fusiform_shaped_tapered_polar_growth 14 5 4 4,3,3,2,2 +data/traits/morphology/gas_vesicle.yaml gas_vesicle_buoyancy 13 5 4 4,3,2,2,2 +data/traits/morphology/gliding.yaml gliding_surface_motility 10 1 10 10 +data/traits/morphology/gram_negative.yaml gram_negative_outer_membrane_dye_loss 17 5 8 8,3,2,2,2 +data/traits/morphology/gram_positive.yaml gram_positive_cell_wall_retention 13 2 11 11,2 +data/traits/morphology/gram_stain.yaml gram_stain_cell_envelope_retention 7 2 5 5,2 +data/traits/morphology/gram_variable.yaml gram_variable_wall_thinning 9 1 9 9 +data/traits/morphology/green_pigmented.yaml green_pigmented_pyocyanin_phenazine 12 1 12 12 +data/traits/morphology/helical_shaped.yaml helical_shaped_pg_relaxation 10 1 10 10 +data/traits/morphology/heterocyst.yaml heterocyst_microoxic_nitrogen_fixation 14 4 5 5,4,3,2 +data/traits/morphology/intracellular_inclusion.yaml inclusion_compartmentalization 14 5 4 4,3,3,2,2 +data/traits/morphology/irregular_shaped.yaml irregular_shaped_loss_of_patterning 10 1 10 10 +data/traits/morphology/lophotrichous.yaml lophotrichous_polar_tuft 10 2 6 6,4 +data/traits/morphology/magnetosome.yaml magnetosome_magnetotaxis 14 4 8 8,2,2,2 +data/traits/morphology/monotrichous.yaml monotrichous_single_polar_flagellum 10 2 7 7,3 +data/traits/morphology/motile.yaml motile_energy_dependent_locomotion 20 1 20 20 +data/traits/morphology/motility.yaml motility_locomotion_machinery 12 1 12 12 +data/traits/morphology/mycelial_growth.yaml mycelial_branching_hyphal_growth 13 4 5 5,4,2,2 +data/traits/morphology/non_motile.yaml non_motile_absent_motility_apparatus 10 1 10 10 +data/traits/morphology/non_spore_forming.yaml non_spore_forming_absent_spo0a_cascade 11 1 11 11 +data/traits/morphology/orange_pigmented.yaml orange_pigmented_carotenoid_accumulation 15 3 8 8,5,2 +data/traits/morphology/oval_shaped.yaml oval_shaped_ovococcal_pg_synthesis 10 1 10 10 +data/traits/morphology/ovoid_shaped.yaml ovoid_shaped_midcell_pg_assembly 13 1 13 13 +data/traits/morphology/peritrichous.yaml peritrichous_surface_distributed_flagella 10 4 4 4,2,2,2 +data/traits/morphology/pigmentation.yaml pigmentation_biosynthetic_colorants 16 4 5 5,5,4,2 +data/traits/morphology/pink_pigmented.yaml pink_pigmented_carotenoid_color 10 1 10 10 +data/traits/morphology/pleomorphic_shaped.yaml pleomorphic_shaped_relaxed_shape_control 10 1 10 10 +data/traits/morphology/polyhydroxyalkanoate_granule.yaml pha_granule_carbon_energy_storage 14 5 6 6,2,2,2,2 +data/traits/morphology/polyphosphate_granule.yaml polyphosphate_granule_storage 7 1 7 7 +data/traits/morphology/prosthecate.yaml prosthecate_stalk_nutrient_uptake 10 3 5 5,3,2 +data/traits/morphology/red_pigmented.yaml red_pigmented_prodiginine_pathway 10 1 10 10 +data/traits/morphology/ring_shaped.yaml ring_shaped_curved_growth_closure 15 6 4 4,3,2,2,2,2 +data/traits/morphology/rod_shaped.yaml rod_shaped_mreB_peptidoglycan 13 2 8 8,5 +data/traits/morphology/s_layer.yaml s_layer_2d_protein_array 4 1 4 4 +data/traits/morphology/sarcina_arrangement.yaml sarcina_three_plane_division_packet 14 5 4 4,3,3,2,2 +data/traits/morphology/sphere_shaped.yaml sphere_shaped_septal_peptidoglycan 10 1 10 10 +data/traits/morphology/spindle_shaped.yaml spindle_shaped_symmetric_taper 6 1 6 6 +data/traits/morphology/spiral_shaped.yaml spiral_shaped_curvature_mechanisms 11 1 11 11 +data/traits/morphology/spirochete_shaped.yaml spirochete_shaped_periplasmic_flagella 11 1 11 11 +data/traits/morphology/spore_forming.yaml spore_forming_endospore_assembly 15 4 7 7,4,2,2 +data/traits/morphology/spore_shaped.yaml spore_shaped_endospore_layers 18 5 9 9,3,2,2,2 +data/traits/morphology/sporulation.yaml sporulation_spo0a_sigma_morphogenesis 19 6 8 8,3,2,2,2,2 +data/traits/morphology/square_shaped.yaml square_shaped_planar_anisotropic_growth 10 3 5 5,3,2 +data/traits/morphology/staphylococcus_arrangement.yaml staphylococcus_irregular_division_cluster 8 2 5 5,3 +data/traits/morphology/star_shaped.yaml star_shaped_multiple_prosthecae 12 3 7 7,3,2 +data/traits/morphology/streptococcus_arrangement.yaml streptococcus_parallel_division_chain 9 1 9 9 +data/traits/morphology/sulfur_globule.yaml sulfur_globule_sulfur_oxidation_intermediate 8 1 8 8 +data/traits/morphology/swarming_motility.yaml swarming_hyperflagellation_surfactant 6 1 6 6 +data/traits/morphology/tailed_shaped.yaml tailed_shaped_polar_stalk_growth 11 1 11 11 +data/traits/morphology/tetrad_arrangement.yaml tetrad_two_plane_division 7 1 7 7 +data/traits/morphology/triangular_shaped.yaml triangular_shaped_planar_polygonal_growth 7 1 7 7 +data/traits/morphology/twitching_motility.yaml twitching_type_iv_pilus_retraction 11 4 5 5,2,2,2 +data/traits/morphology/vibrio_shaped.yaml vibrio_shaped_crvA_curvature 10 1 10 10 +data/traits/morphology/white_pigmented.yaml white_pigmented_low_chromophore 11 1 11 11 +data/traits/morphology/yellow_pigmented.yaml yellow_pigmented_carotenoid_color 15 2 9 9,6 +data/traits/physiology/antibiotic_resistance.yaml antibiotic_resistance_mechanisms 12 5 4 4,2,2,2,2 +data/traits/physiology/autotrophic.yaml autotrophic_inorganic_carbon_fixation 18 2 16 16,2 +data/traits/physiology/bioluminescence.yaml bioluminescence_luciferase 13 1 13 13 +data/traits/physiology/carboxydotrophic.yaml carboxydotrophic_co_oxidation 18 2 14 14,4 +data/traits/physiology/catalase_activity.yaml catalase_activity_h2o2_detoxification 10 1 10 10 +data/traits/physiology/chemoautolithotrophic.yaml chemoautolithotrophic_inorganic_energy_co2_fixation 11 1 11 11 +data/traits/physiology/chemoautotrophic.yaml chemoautotrophic_chemical_energy_co2_fixation 16 1 16 16 +data/traits/physiology/chemoheterotrophic.yaml chemoheterotrophic_organic_energy_carbon 14 2 12 12,2 +data/traits/physiology/chemolithoautotrophic.yaml chemolithoautotrophic_energy_and_fixation 13 1 13 13 +data/traits/physiology/chemolithoheterotrophic.yaml chemolithoheterotrophic_inorganic_energy_organic_carbon 16 3 11 11,3,2 +data/traits/physiology/chemolithotrophic.yaml chemolithotrophic_inorganic_oxidation 15 5 4 4,4,3,2,2 +data/traits/physiology/chemoorganoheterotrophic.yaml chemoorganoheterotrophic_organic_energy_carbon 13 2 11 11,2 +data/traits/physiology/chemoorganotrophic.yaml chemoorganotrophic_organic_oxidation_energy 17 3 13 13,2,2 +data/traits/physiology/chemotaxis.yaml chemotaxis_gradient_response 12 2 9 9,3 +data/traits/physiology/chemotrophic.yaml chemotrophic_chemical_redox_energy 13 1 13 13 +data/traits/physiology/copiotrophic.yaml copiotrophic_high_nutrient_fast_growth 15 2 13 13,2 +data/traits/physiology/dormancy.yaml dormancy_seed_bank 10 2 7 7,3 +data/traits/physiology/heterotrophic.yaml heterotrophic_organic_carbon_assimilation 12 1 12 12 +data/traits/physiology/hydrogenotrophic.yaml hydrogenotrophic_hydrogen_oxidation_fixation 14 1 14 14 +data/traits/physiology/lithoautotrophic.yaml lithoautotrophic_inorganic_donor_co2_fixation 15 3 8 8,4,3 +data/traits/physiology/lithoheterotrophic.yaml lithoheterotrophic_inorganic_energy_organic_carbon 18 2 16 16,2 +data/traits/physiology/lithotrophic.yaml lithotrophic_inorganic_donor_energy 19 7 7 7,2,2,2,2,2,2 +data/traits/physiology/methanotrophic.yaml methanotrophic_methane_oxidation 20 5 8 8,6,2,2,2 +data/traits/physiology/methylotrophic.yaml methylotrophic_methanol_assimilation 19 4 12 12,3,2,2 +data/traits/physiology/mixotrophic.yaml mixotrophic_dual_carbon_energy_use 14 1 14 14 +data/traits/physiology/natural_competence.yaml natural_competence_dna_uptake 12 4 4 4,3,3,2 +data/traits/physiology/nutrient_adaptation.yaml nutrient_adaptation_life_history_axis 13 3 8 8,3,2 +data/traits/physiology/oligotrophic.yaml oligotrophic_low_nutrient_efficiency 11 1 11 11 +data/traits/physiology/organoheterotrophic.yaml organoheterotrophic_organic_donor_carbon 12 1 12 12 +data/traits/physiology/organotrophic.yaml organotrophic_organic_compound_oxidation 11 1 11 11 +data/traits/physiology/oxidase_activity.yaml oxidase_activity_terminal_oxidase 11 2 6 6,5 +data/traits/physiology/oxidative_stress_response.yaml oxidative_stress_response_ros_defense 6 1 6 6 +data/traits/physiology/persister_cell_formation.yaml persister_dormancy_tolerance 10 2 8 8,2 +data/traits/physiology/photoautotrophic.yaml photoautotrophic_cyanobacterial_carbon_fixation 16 4 9 9,3,2,2 +data/traits/physiology/photoheterotrophic.yaml photoheterotrophic_light_organic_carbon 16 2 14 14,2 +data/traits/physiology/photolithoautotrophic.yaml photolithoautotrophic_light_inorganic_donor_fixation 15 3 10 10,3,2 +data/traits/physiology/photolithotrophic.yaml photolithotrophic_inorganic_electron_donors 14 3 10 10,2,2 +data/traits/physiology/photoorganoheterotrophic.yaml photoorganoheterotrophic_light_organic_electrons 16 4 10 10,2,2,2 +data/traits/physiology/phototrophic.yaml phototrophic_light_energy_capture 13 2 11 11,2 +data/traits/physiology/quorum_sensing.yaml quorum_sensing_autoinducer 7 1 7 7 +data/traits/physiology/spore_germination.yaml spore_germination_germinant_trigger 6 1 6 6 +data/traits/physiology/stress_response.yaml stress_response_induction 11 1 11 11 +data/traits/physiology/trophic_type.yaml trophic_type_classification_axes 14 1 14 14 +data/traits/physiology/urease_activity.yaml urease_activity_urea_hydrolysis 11 1 11 11 +data/traits/physiology/viable_but_nonculturable_state.yaml vbnc_stress_induced_dormancy 11 2 6 6,5 +data/traits/upper/biological_process.yaml biological_process_go_upper_context 8 1 8 8 +data/traits/upper/material_entity.yaml material_entity_bfo_upper_context 4 1 4 4 +data/traits/upper/observation.yaml observation_measurement_upper_context 14 5 5 5,3,2,2,2 +data/traits/upper/phenotype.yaml phenotype_quality_child_context 5 1 5 5 +data/traits/upper/quality.yaml quality_upper_child_context 4 1 4 4 diff --git a/scripts/audit_causal_graphs.py b/scripts/audit_causal_graphs.py index f5cac22d..9f7f7f47 100644 --- a/scripts/audit_causal_graphs.py +++ b/scripts/audit_causal_graphs.py @@ -100,7 +100,10 @@ REPO_ROOT = Path(__file__).resolve().parent.parent TRAITS_DIR = REPO_ROOT / "data" / "traits" DEFAULT_OUT = REPO_ROOT / "reports" / "causal_graph_audit.tsv" +DEFAULT_CONNECTIVITY = REPO_ROOT / "reports" / "causal_graph_connectivity.tsv" DEFAULT_BASELINE = REPO_ROOT / "conf" / "causal_graph_audit_baseline.tsv" +CONNECTIVITY_FIELDS = ("file", "graph_id", "wired_nodes", "components", + "largest_component", "component_sizes") ERROR = "ERROR" WARN = "WARN" @@ -152,6 +155,83 @@ def _components(node_set: set[str], adjacency: dict[str, set[str]]) -> list[set[ return sorted(out, key=len, reverse=True) +def _topology(graph: dict) -> tuple[set, set, dict[str, set[str]]]: + """``(declared node ids, edge-referenced ids, undirected adjacency)``. + + Shared by :func:`audit` and :func:`connectivity_rows` so the ratchet and + the metric can never disagree about what "connected" means. Only edges + whose BOTH ends are declared are wired, so a dangling edge cannot + fabricate reachability through a phantom node — the audit reports that + separately as DANGLING_EDGE. + """ + node_set = {n.get("node_id") for n in (graph.get("nodes") or [])} + referenced: set = set() + adjacency: dict[str, set[str]] = defaultdict(set) + for e in (graph.get("edges") or []): + subj, obj = e.get("subject"), e.get("object") + referenced.add(subj) + referenced.add(obj) + if subj in node_set and obj in node_set: + adjacency[subj].add(obj) + adjacency[obj].add(subj) + return node_set, referenced, adjacency + + +def connectivity_rows(traits_dir: Path) -> list[dict[str, str]]: + """Per-graph connectivity, the metric #359 asked for. + + Neither headline count can tell a real connectivity gain from an anchor + added inside an island. #352 is the worked example: `oxygen_tolerance` + could be RETYPED to TRAIT (wrong -- the grounding contradicted the graph) + or MERGED into `oxygen_preference_trait` (right), and + UNREACHABLE_FROM_TRAIT lands on 1296 either way, because retyping simply + added an anchor the island's nodes could already reach. FRAGMENTED_GRAPH's + *count* is equally blind: it reports one finding per split graph however + many pieces it is in, so 3 components -> 2 does not move it. + + What separates them is the component structure itself: + + retyped: components=3 of 14 (sizes: 8, 4, 2) <- island still an island + merged: components=2 of 13 (sizes: 11, 2) <- island attached + + So this reports, per graph, the component count and the share of nodes in + the largest one. Both are anchor-free, for the reason :func:`_components` + documents: they ask "is this one graph?" without needing to know which + node the record is about, so no amount of retyping or renaming moves them. + Scoped to edge-referenced nodes, matching FRAGMENTED_GRAPH — an unwired + node is ORPHAN_NODE's business and counting it here would let one defect + depress two metrics. + """ + rows: list[dict[str, str]] = [] + for path in sorted(traits_dir.rglob("*.yaml")): + try: + doc = yaml.safe_load(path.read_text()) + except yaml.YAMLError: + continue + if not isinstance(doc, dict): + continue + try: + rel = str(path.relative_to(REPO_ROOT)) + except ValueError: + rel = str(path) + for graph in (doc.get("causal_graphs") or []): + node_set, referenced, adjacency = _topology(graph) + wired = node_set & referenced + if not wired: + continue + components = _components(wired, adjacency) + largest = len(components[0]) + rows.append({ + "file": rel, + "graph_id": graph.get("graph_id", ""), + "wired_nodes": str(len(wired)), + "components": str(len(components)), + "largest_component": str(largest), + "component_sizes": ",".join(str(len(c)) for c in components), + }) + return rows + + # Matched against the DESCRIPTION only, never the label: the label is usually # just the concept name ("buoyancy", "salt tolerance") while the description is # where the curator says what it IS. @@ -190,25 +270,21 @@ def audit(traits_dir: Path) -> list[dict[str, str]]: for graph in (doc.get("causal_graphs") or []): gid = graph.get("graph_id", "") nodes = graph.get("nodes") or [] - node_set = {n.get("node_id") for n in nodes} - referenced: set = set() - adjacency: dict[str, set[str]] = defaultdict(set) + # Topology comes from the shared helper so this ratchet and + # connectivity_rows() cannot drift apart on what "connected" means; + # the DANGLING_EDGE pass below stays here because it is a finding, + # not topology. + node_set, referenced, adjacency = _topology(graph) for e in (graph.get("edges") or []): subj, obj = e.get("subject"), e.get("object") for end, ref in (("subject", subj), ("object", obj)): - referenced.add(ref) if ref not in node_set: findings.append({ "file": rel, "graph_id": gid, "defect": "DANGLING_EDGE", "severity": SEVERITY["DANGLING_EDGE"], "detail": f"{end}={ref!r} ({subj} -[{e.get('predicate')}]-> {obj})", }) - # Only wire up edges whose ends both exist, so a dangling edge - # cannot fabricate reachability through a phantom node. - if subj in node_set and obj in node_set: - adjacency[subj].add(obj) - adjacency[obj].add(subj) for n in nodes: if n.get("node_id") not in referenced: @@ -371,6 +447,11 @@ def load_baseline(path: Path) -> set[tuple[str, str, str, str]]: def main() -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--out", type=Path, default=DEFAULT_OUT) + ap.add_argument("--connectivity-out", type=Path, default=None, + help="per-graph connectivity TSV (#359). Written on every run; " + "it is a measurement, not a verdict, and never affects the " + "exit code. Defaults NEXT TO --out rather than to a fixed " + "repo path, so redirecting the report redirects this too.") ap.add_argument("--traits-dir", type=Path, default=TRAITS_DIR) ap.add_argument("--baseline", type=Path, default=DEFAULT_BASELINE, help="TSV of known findings to suppress from the exit code.") @@ -394,6 +475,23 @@ def main() -> int: w.writeheader() w.writerows(findings) + # Derived from --out, not a fixed repo path. A caller that redirects the + # report to a temp dir -- every test that runs this via subprocess, and the + # staleness check in audit-derived-reports -- would otherwise still write + # THIS file into the working tree. That is not hypothetical: it clobbered + # the committed report with a single row naming a pytest tmpdir, and the + # staleness gate is what caught it. + if args.connectivity_out is None: + args.connectivity_out = args.out.parent / DEFAULT_CONNECTIVITY.name + + conn = connectivity_rows(args.traits_dir) + args.connectivity_out.parent.mkdir(parents=True, exist_ok=True) + with args.connectivity_out.open("w", newline="") as f: + w = csv.DictWriter(f, fieldnames=CONNECTIVITY_FIELDS, delimiter="\t", + lineterminator="\n") + w.writeheader() + w.writerows(conn) + if args.write_baseline: # The baseline parks the known WARN backlog so the check can run # non-blocking. It is NOT a suppression channel for structural errors: @@ -431,6 +529,24 @@ def main() -> int: for d, n in sorted(by_defect.items()): print(f" {d:<22} {n:>5} [{SEVERITY.get(d, WARN)}]", file=sys.stderr) print(f" TSV: {args.out}", file=sys.stderr) + + # Reported alongside, never folded into the finding counts: this measures + # the corpus, it does not judge it (#359). `attached` is the share of wired + # nodes sitting in their graph's largest component -- the number that moves + # when an island is genuinely joined and stays flat when a node is merely + # retyped into an anchor. + total_wired = sum(int(r["wired_nodes"]) for r in conn) + total_components = sum(int(r["components"]) for r in conn) + total_largest = sum(int(r["largest_component"]) for r in conn) + if total_wired: + pct = 100.0 * total_largest / total_wired + print(f" connectivity: {len(conn)} graph(s), {total_components} component(s) " + f"over {total_wired} wired node(s); attached " + f"{total_largest}/{total_wired} ({pct:.1f}%)", file=sys.stderr) + else: + print(" connectivity: no wired nodes", file=sys.stderr) + print(f" connectivity TSV: {args.connectivity_out}", file=sys.stderr) + for r in (blocking or new)[:20]: print(f" {r['severity']} {r['defect']} {r['file']} [{r['graph_id']}]" f" {r['detail']}", file=sys.stderr) diff --git a/tests/test_audit_causal_graphs.py b/tests/test_audit_causal_graphs.py index 58e38bd9..65b5ccf2 100644 --- a/tests/test_audit_causal_graphs.py +++ b/tests/test_audit_causal_graphs.py @@ -27,6 +27,7 @@ SEVERITY, _key, audit, + connectivity_rows, partition, ) @@ -596,3 +597,144 @@ def test_two_groundings_of_equal_size_do_not_collide(tmp_path): dupes = [f for f in audit(d) if f["defect"] == "DUPLICATE_GROUNDING"] assert len(dupes) == 2 assert len({_key(f) for f in dupes}) == 2 + + +# ------------------------------------------------- connectivity metric (#359) +# +# The property under test is the one #359 was filed for: a node RETYPED into a +# TRAIT anchor inside an existing island moves UNREACHABLE_FROM_TRAIT without +# connecting anything, while MERGING that node attaches the island for real. +# The fixtures below are the shape of oxygen_preference.yaml reduced to its +# essentials -- a trait with one wired-in phenotype, plus a detached two-node +# island whose member is the candidate. + +ISLAND_BEFORE = """\ +identifier: traitmech:000900 +label: t +causal_graphs: +- graph_id: g + nodes: + - {node_id: trait, label: trait, node_type: TRAIT} + - {node_id: pheno, label: pheno, node_type: TRAIT} + - {node_id: tolerance, label: tolerance, node_type: CAPACITY} + - {node_id: enzyme, label: enzyme, node_type: GENE_OR_PROTEIN} + edges: + - {subject: pheno, object: trait, predicate: is a} + - {subject: enzyme, object: tolerance, predicate: increases} +""" + +# The wrong fix: `tolerance` becomes a TRAIT. Nothing is rewired. +ISLAND_RETYPED = ISLAND_BEFORE.replace( + "{node_id: tolerance, label: tolerance, node_type: CAPACITY}", + "{node_id: tolerance, label: tolerance, node_type: TRAIT}") + +# The right fix: `tolerance` is merged away and its edge repointed at `trait`. +ISLAND_MERGED = """\ +identifier: traitmech:000900 +label: t +causal_graphs: +- graph_id: g + nodes: + - {node_id: trait, label: trait, node_type: TRAIT} + - {node_id: pheno, label: pheno, node_type: TRAIT} + - {node_id: enzyme, label: enzyme, node_type: GENE_OR_PROTEIN} + edges: + - {subject: pheno, object: trait, predicate: is a} + - {subject: enzyme, object: trait, predicate: increases} +""" + + +def _isolated(tmp_path: Path, sub: str, body: str) -> Path: + """A corpus dir of its own. + + ``_write`` reuses one directory, so two fixtures in a single test would + land in the same corpus and every walk would see both. + """ + d = tmp_path / sub / "traits" + d.mkdir(parents=True) + (d / "rec.yaml").write_text(textwrap.dedent(body)) + return d + + +def _conn(tmp_path: Path, sub: str, body: str) -> dict: + row, = connectivity_rows(_isolated(tmp_path, sub, body)) + return row + + +def test_connectivity_reports_components_and_largest(tmp_path): + row = _conn(tmp_path, "before", ISLAND_BEFORE) + assert row["wired_nodes"] == "4" + assert row["components"] == "2" + assert row["largest_component"] == "2" + assert row["component_sizes"] == "2,2" + + +def test_retyping_into_an_anchor_does_not_move_connectivity(tmp_path): + """#359's whole point. The retype silences UNREACHABLE_FROM_TRAIT for the + island -- and leaves the connectivity metric bit-for-bit unchanged, + because nothing was actually joined.""" + before = _conn(tmp_path, "before", ISLAND_BEFORE) + after = _conn(tmp_path, "after", ISLAND_RETYPED) + + assert {k: v for k, v in after.items() if k != "file"} == \ + {k: v for k, v in before.items() if k != "file"} + + # ... while the finding count DOES move, which is the trap. + unreachable_before = [f for f in audit(_isolated(tmp_path, "b2", ISLAND_BEFORE)) + if f["defect"] == "UNREACHABLE_FROM_TRAIT"] + unreachable_after = [f for f in audit(_isolated(tmp_path, "a2", ISLAND_RETYPED)) + if f["defect"] == "UNREACHABLE_FROM_TRAIT"] + assert len(unreachable_before) > len(unreachable_after) + + +def test_merging_the_node_does_move_connectivity(tmp_path): + before = _conn(tmp_path, "before", ISLAND_BEFORE) + after = _conn(tmp_path, "after", ISLAND_MERGED) + assert before["components"] == "2" and after["components"] == "1" + assert before["largest_component"] == "2" and after["largest_component"] == "3" + + +def test_connectivity_skips_unwired_nodes(tmp_path): + """An edgeless node is ORPHAN_NODE's business; counting it here would let + one defect depress two metrics.""" + body = ISLAND_BEFORE.replace( + " edges:", + " - {node_id: lonely, label: lonely, node_type: CHEMICAL}\n edges:", 1) + row = _conn(tmp_path, "orphan", body) + assert row["wired_nodes"] == "4" + assert row["component_sizes"] == "2,2" + + +def test_connectivity_row_per_graph_not_per_file(tmp_path): + two = ISLAND_BEFORE + textwrap.dedent("""\ + - graph_id: g2 + nodes: + - {node_id: t2, label: t2, node_type: TRAIT} + - {node_id: x2, label: x2, node_type: CHEMICAL} + edges: + - {subject: x2, object: t2, predicate: affects} + """) + rows = connectivity_rows(_isolated(tmp_path, "two", two)) + assert [r["graph_id"] for r in rows] == ["g", "g2"] + assert [r["components"] for r in rows] == ["2", "1"] + + +def test_connectivity_out_defaults_next_to_out_not_into_the_repo(tmp_path): + """Regression: the connectivity report must follow --out. + + With a fixed repo default, every subprocess test that redirected --out to a + tmpdir still wrote this file into the working tree -- one of them clobbered + the committed report with a single row naming a pytest tmpdir, and only the + staleness gate noticed. A side effect that ignores --out is a side effect + that lands in the repo. + """ + d = _isolated(tmp_path, "corpus", ISLAND_BEFORE) + out = tmp_path / "elsewhere" / "audit.tsv" + subprocess.run( + [sys.executable, str(REPO_ROOT / "scripts" / "audit_causal_graphs.py"), + "--traits-dir", str(d), "--out", str(out), "--no-baseline"], + check=False, capture_output=True, + ) + assert (out.parent / "causal_graph_connectivity.tsv").exists() + assert not (REPO_ROOT / "reports" / "causal_graph_connectivity.tsv").samefile( + out.parent / "causal_graph_connectivity.tsv")