descriptron-core 2.5.2__tar.gz → 2.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/PKG-INFO +1 -1
  2. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/pyproject.toml +1 -1
  3. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biosyslit_rag_retrieval_v2.py +42 -0
  4. descriptron_core-2.7.0/src/descriptron_core/tools/coi_species_tree_v1.py +151 -0
  5. descriptron_core-2.7.0/src/descriptron_core/tools/descriptron_character_signal_v1.py +346 -0
  6. descriptron_core-2.7.0/src/descriptron_core/tools/descriptron_check_cross_image_copies_v1.py +200 -0
  7. descriptron_core-2.7.0/src/descriptron_core/tools/descriptron_labelled_plates_v1.py +290 -0
  8. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/descriptron_phylo.py +5 -1
  9. descriptron_core-2.7.0/src/descriptron_core/tools/descriptron_phylo_figure_v1.py +258 -0
  10. descriptron_core-2.7.0/src/descriptron_core/tools/descriptron_reexamine_v1.py +448 -0
  11. descriptron_core-2.7.0/src/descriptron_core/tools/descriptron_reexamine_v2.py +923 -0
  12. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/descriptron_trait_stats.py +43 -0
  13. descriptron_core-2.7.0/src/descriptron_core/tools/dna_vs_morphology_v1.py +200 -0
  14. descriptron_core-2.7.0/src/descriptron_core/tools/generate_species_plates.py +299 -0
  15. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/run_full_pipeline_v2.py +128 -19
  16. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/.gitignore +0 -0
  17. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/LICENSE +0 -0
  18. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/NOTICE +0 -0
  19. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/README.md +0 -0
  20. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/__init__.py +0 -0
  21. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/cli.py +0 -0
  22. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/biorag_system_prompts_v1.txt +0 -0
  23. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/biorag_system_prompts_v2.txt +0 -0
  24. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/diaphorina_user_prompts.txt +0 -0
  25. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/ontologies/aism.obo +0 -0
  26. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/ontologies/pato.obo +0 -0
  27. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/SDD.xsd +0 -0
  28. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/SDD_EnumLib.xsd +0 -0
  29. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/SDD_TypeLib.xsd +0 -0
  30. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/UBIF_BasePattern.xsd +0 -0
  31. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/UBIF_CoreExtensions.xsd +0 -0
  32. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/UBIF_CoreOntology.xsd +0 -0
  33. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/UBIF_EnumLib.xsd +0 -0
  34. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/UBIF_LinneanCore.xsd +0 -0
  35. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/UBIF_TypeLib.xsd +0 -0
  36. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/schemas/sdd_1.1/w3c-schemata/xml.xsd +0 -0
  37. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/schemas/taxpub/sample_treatment_deposit_example.xml +0 -0
  38. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/schemas/taxpub/sample_zookeys_26056_tp.xml +0 -0
  39. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/schemas/taxpub/tax-treatment-NS0-v1_flat.dtd +0 -0
  40. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/taxon_profiles/TEMPLATE_taxon_profile.yaml +0 -0
  41. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/data/biorag_prompts/taxon_profiles/diaphorina_taxon_profile.yaml +0 -0
  42. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/FHS_and_CLAHE_V21.py +0 -0
  43. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_add_discrete_characters_v1.py +0 -0
  44. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_annotation_reorient_v1.py +0 -0
  45. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_annotation_screen_v1.py +0 -0
  46. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_autapomorphy_v1.py +0 -0
  47. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_calibrate_v1.py +0 -0
  48. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_calibration_figures_v1.py +0 -0
  49. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_character_gate_v1.py +0 -0
  50. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_character_reliability_v1.py +0 -0
  51. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_character_robustness_v1.py +0 -0
  52. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_collaborator_forms_v1.py +0 -0
  53. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_computed_sets_v1.py +0 -0
  54. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_confabulation_checker_v2.py +0 -0
  55. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_congruence_compare_v1.py +0 -0
  56. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_description_refiner_v1.py +0 -0
  57. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_descriptive_scoring_v1.py +0 -0
  58. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_feature_policy.py +0 -0
  59. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_frame_orientation_calibrate_v1.py +0 -0
  60. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_frame_orientation_v1.py +0 -0
  61. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_gate_compare_v1.py +0 -0
  62. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_graph_identify_v1.py +0 -0
  63. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_graph_support_figure_v1.py +0 -0
  64. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_homology_frame_v1.py +0 -0
  65. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_instrument_compare_v3.py +0 -0
  66. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_key_builder_v1.py +0 -0
  67. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_key_feature_filter_v2.py +0 -0
  68. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_key_fuzzy_figures_v1.py +0 -0
  69. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_key_fuzzy_v1.py +0 -0
  70. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_key_holdout_v1.py +0 -0
  71. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_key_jackknife_support_v1.py +0 -0
  72. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_label_conflict_check_v1.py +0 -0
  73. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_llm_backend.py +0 -0
  74. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_make_taxon_profile_v1.py +0 -0
  75. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_methods_summary_figure_v1.py +0 -0
  76. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_novelty_score_v1.py +0 -0
  77. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_ontology_annotator_v2.py +0 -0
  78. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_outgroup_batch_check_v1.py +0 -0
  79. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_outline_shape_v1.py +0 -0
  80. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/biorag_system_prompts_v1.txt +0 -0
  81. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/biorag_system_prompts_v2.txt +0 -0
  82. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/diaphorina_user_prompts.txt +0 -0
  83. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/ontologies/aism.obo +0 -0
  84. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/ontologies/pato.obo +0 -0
  85. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/SDD.xsd +0 -0
  86. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/SDD_EnumLib.xsd +0 -0
  87. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/SDD_TypeLib.xsd +0 -0
  88. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/UBIF_BasePattern.xsd +0 -0
  89. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/UBIF_CoreExtensions.xsd +0 -0
  90. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/UBIF_CoreOntology.xsd +0 -0
  91. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/UBIF_EnumLib.xsd +0 -0
  92. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/UBIF_LinneanCore.xsd +0 -0
  93. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/UBIF_TypeLib.xsd +0 -0
  94. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/schemas/sdd_1.1/w3c-schemata/xml.xsd +0 -0
  95. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/schemas/taxpub/sample_treatment_deposit_example.xml +0 -0
  96. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/schemas/taxpub/sample_zookeys_26056_tp.xml +0 -0
  97. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/schemas/taxpub/tax-treatment-NS0-v1_flat.dtd +0 -0
  98. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/taxon_profiles/TEMPLATE_taxon_profile.yaml +0 -0
  99. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_prompts/taxon_profiles/diaphorina_taxon_profile.yaml +0 -0
  100. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_provenance_v1.py +0 -0
  101. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_rebuild_downstream_v1.sh +0 -0
  102. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_reliability_figures_v1.py +0 -0
  103. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_rename_species_v1.py +0 -0
  104. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_rerun_descriptive_tables_v1.sh +0 -0
  105. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_retest_compare_v1.py +0 -0
  106. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_specimen_id.py +0 -0
  107. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_subjective_checks_v1.py +0 -0
  108. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_type_material_v1.py +0 -0
  109. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_vlm_characters_v1.py +0 -0
  110. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_vlm_heatmap_figures_v1.py +0 -0
  111. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_vlm_reliability_figure_v1.py +0 -0
  112. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biorag_vlm_roi_figures_v1.py +0 -0
  113. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/biosyslit_rag_retrieval.py +0 -0
  114. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/build_monograph_exports_v1.py +0 -0
  115. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/build_species_treatment_docx_v2.py +0 -0
  116. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/coco_combiner_V13.py +0 -0
  117. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/coco_converter_v24.py +0 -0
  118. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/color_extraction_with_names-v8.py +0 -0
  119. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/color_phenomics_homology_v2_1.py +0 -0
  120. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/compile_specimen_data.py +0 -0
  121. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/descriptron_centerline.py +0 -0
  122. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/descriptron_coco_tools.py +0 -0
  123. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/descriptron_convert.py +0 -0
  124. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/descriptron_credentials.py +0 -0
  125. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/descriptron_joints.py +0 -0
  126. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/descriptron_metadata.py +0 -0
  127. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/descriptron_rosetta.py +0 -0
  128. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/descriptron_shape_stats.py +0 -0
  129. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/landmark_gpa_V1.py +0 -0
  130. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/landmark_gpa_V2.py +0 -0
  131. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/make_reproducibility_bundle.py +0 -0
  132. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/semi_landmark_and_kpts_procrustesV34_GPA.py +0 -0
  133. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/semi_landmark_and_kpts_procrustesV42_GPA.py +0 -0
  134. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/texture_phenomics_from_coco.py +0 -0
  135. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/texture_phenomics_homology.py +0 -0
  136. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/validate_centerline_lengths.py +0 -0
  137. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/validate_phylo_published_waldron2025.py +0 -0
  138. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/validate_phylo_real_tree.py +0 -0
  139. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/validate_phylo_traits.py +0 -0
  140. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/validate_pipeline_gm_vs_geomorph.py +0 -0
  141. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/validate_semilandmarks_vs_geomorph.py +0 -0
  142. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/validate_shape_stats_vs_geomorph.py +0 -0
  143. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/validate_shape_stats_vs_rrpp.py +0 -0
  144. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/validate_texture_glcm.py +0 -0
  145. {descriptron_core-2.5.2 → descriptron_core-2.7.0}/src/descriptron_core/tools/validate_trait_stats.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: descriptron-core
3
- Version: 2.5.2
3
+ Version: 2.7.0
4
4
  Summary: Evidence-tiered species descriptions, keys and audits from phenomic matrices
5
5
  Project-URL: Homepage, https://github.com/alexrvandam/Descriptron
6
6
  Project-URL: Repository, https://github.com/alexrvandam/Descriptron
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "descriptron-core"
7
- version = "2.5.2"
7
+ version = "2.7.0"
8
8
  description = "Evidence-tiered species descriptions, keys and audits from phenomic matrices"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -64,6 +64,48 @@ from biorag_llm_backend import load_prompt_library, make_llm_client # noqa: E40
64
64
 
65
65
  V2_VERSION = "2.0"
66
66
 
67
+ # ── retrieval log (opt-in): BIORAG_RETRIEVAL_LOG=<file.jsonl> records every literature search and every passage
68
+ # returned (source, DOI, section, score), so a run can show WHERE in the literature the model was primed from.
69
+ # BIORAG_RETRIEVAL_CONTEXT (e.g. the species code) is copied into each record. v1 is not modified: the two
70
+ # functions are wrapped here.
71
+ _RLOG = os.environ.get("BIORAG_RETRIEVAL_LOG")
72
+ if _RLOG:
73
+ import datetime as _dt
74
+ import json as _json
75
+
76
+ def _rlog_write(rec):
77
+ rec = {"time": _dt.datetime.now().isoformat(timespec="seconds"),
78
+ "context": os.environ.get("BIORAG_RETRIEVAL_CONTEXT", ""), **rec}
79
+ with open(_RLOG, "a") as _f:
80
+ _f.write(_json.dumps(rec, ensure_ascii=False) + "\n")
81
+
82
+ _orig_retrieve = base.TreatmentIndex.retrieve
83
+
84
+ def _logged_retrieve(self, query, _orig=_orig_retrieve):
85
+ res = _orig(self, query)
86
+ _rlog_write({"kind": "index_retrieve",
87
+ "query": {k: getattr(query, k, "") for k in ("taxon_name", "taxon_family", "body_region",
88
+ "description_mode", "free_text", "k")},
89
+ "index_chunks": len(getattr(self, "chunks", []) or []),
90
+ "results": [{"rank": i + 1, "score": round(float(r.score), 4), "chunk_id": r.chunk.chunk_id,
91
+ "source_type": r.chunk.source_type, "source_id": r.chunk.source_id,
92
+ "source_doi": r.chunk.source_doi, "source_title": r.chunk.source_title,
93
+ "section": r.chunk.description_section, "body_region": r.chunk.body_region,
94
+ "match_reasons": list(r.match_reasons or []),
95
+ "text_preview": (r.chunk.text or "")[:240]} for i, r in enumerate(res)]})
96
+ return res
97
+ base.TreatmentIndex.retrieve = _logged_retrieve
98
+
99
+ _orig_search = base.BioSysLitIngester.search
100
+
101
+ def _logged_search(self, query, *a, _orig=_orig_search, **kw):
102
+ recs = _orig(self, query, *a, **kw)
103
+ _rlog_write({"kind": "biosyslit_search", "query": query,
104
+ "records": [{"id": r.get("id"), "doi": r.get("doi", ""),
105
+ "title": (r.get("metadata", {}) or {}).get("title", "")} for r in (recs or [])]})
106
+ return recs
107
+ base.BioSysLitIngester.search = _logged_search
108
+
67
109
  SUBCOMMAND_MODULES = {
68
110
  "build-key": "biorag_key_builder_v1",
69
111
  "refine-descriptions": "biorag_description_refiner_v1",
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ coi_species_tree_v1.py - a species tree for the phylogenetic comparative analyses, from a barcode gene tree
4
+ ==========================================================================================================
5
+
6
+ 1. midpoint-root the gene tree (e.g. IQ-TREE .treefile; support values on internal nodes are kept)
7
+ 2. per morphospecies: is it monophyletic in the rooted tree? (species with >= 2 sequences)
8
+ 3. keep one sequence per morphospecies and name the tip by the species code, so the tree can be passed to
9
+ descriptron_phylo.py --tree with the group labels. The sequence kept is the first, in this order, of:
10
+ a sequence whose DNA nearest neighbour is its own species and that is also a matrix specimen; one whose
11
+ nearest neighbour is its own species; any. Sequences are mapped to species with the table written by
12
+ dna_vs_morphology_v1.py (specimens_dna_vs_matrix.tsv); other sequences (references) are dropped.
13
+
14
+ python coi_species_tree_v1.py --tree coi.treefile --specimens dna/specimens_dna_vs_matrix.tsv --out_dir tree/
15
+ """
16
+ import argparse
17
+ import json
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ import pandas as pd
22
+
23
+ # measure/ in the source tree; the same folder in the installed package (tools/)
24
+ for _d in (Path(__file__).resolve().parent.parent, Path(__file__).resolve().parent):
25
+ sys.path.insert(0, str(_d))
26
+ from descriptron_phylo import Node, parse_newick, prune, tips_of, _set_depths # noqa: E402
27
+
28
+
29
+ def to_newick(n: Node, support: bool = True) -> str:
30
+ if not n.kids:
31
+ return f"{n.name}:{n.length:.6g}"
32
+ lab = n.name if support else ""
33
+ return "(" + ",".join(to_newick(k, support) for k in n.kids) + f"){lab}:{n.length:.6g}"
34
+
35
+
36
+ def midpoint_root(root: Node) -> Node:
37
+ """re-root at the midpoint of the longest tip-to-tip path."""
38
+ nb = {} # undirected graph: node -> [(node, length)]
39
+ stack = [root]
40
+ while stack:
41
+ n = stack.pop(); nb.setdefault(n, [])
42
+ for k in n.kids:
43
+ nb[n].append((k, k.length)); nb.setdefault(k, []).append((n, k.length)); stack.append(k)
44
+
45
+ def far(src):
46
+ dist, prev, st = {src: 0.0}, {src: None}, [src]
47
+ while st:
48
+ u = st.pop()
49
+ for v, w in nb[u]:
50
+ if v not in dist:
51
+ dist[v] = dist[u] + w; prev[v] = u; st.append(v)
52
+ t = max((x for x in dist if not x.kids), key=dist.get)
53
+ return t, dist, prev
54
+ a, _, _ = far(next(x for x in nb if not x.kids))
55
+ b, dist, prev = far(a)
56
+ half = dist[b] / 2
57
+ x = b # walk back from b towards a until past the midpoint
58
+ while dist[prev[x]] > half:
59
+ x = prev[x]
60
+ u, v = prev[x], x # the midpoint lies on edge u-v
61
+ new = Node("", 0.0)
62
+ w = next(l for y, l in nb[u] if y is v)
63
+ nb[u] = [(y, l) for y, l in nb[u] if y is not v]; nb[v] = [(y, l) for y, l in nb[v] if y is not u]
64
+ nb[new] = [(u, half - dist[u]), (v, dist[v] - half)]
65
+ nb[u].append((new, half - dist[u])); nb[v].append((new, dist[v] - half))
66
+ del w
67
+ # rebuild the parent/child structure from the new root; support labels stay with the clade below each node
68
+ old_parent = {}
69
+ st = [root]
70
+ while st:
71
+ n = st.pop()
72
+ for k in n.kids:
73
+ old_parent[k] = n; st.append(k)
74
+
75
+ def build(n, parent, length):
76
+ n.parent, n.length = parent, length
77
+ kids = [(y, l) for y, l in nb[n] if y is not parent]
78
+ if kids and parent is not None and old_parent.get(parent) is n:
79
+ n.name = "" # direction reversed: the old support no longer applies
80
+ n.kids = []
81
+ for y, l in kids:
82
+ n.kids.append(build(y, n, l))
83
+ if n.kids and len(n.kids) == 1: # the old root, left with one child: collapse it
84
+ c = n.kids[0]; c.length += n.length; c.parent = parent
85
+ return c
86
+ return n
87
+ r = build(new, None, 0.0)
88
+ _set_depths(r)
89
+ return r
90
+
91
+
92
+ def clade_tips(n):
93
+ return {t.name for t in tips_of(n)}
94
+
95
+
96
+ def main(argv=None):
97
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
98
+ ap.add_argument("--tree", required=True)
99
+ ap.add_argument("--specimens", required=True, help="specimens_dna_vs_matrix.tsv from dna_vs_morphology_v1.py")
100
+ ap.add_argument("--min_tip_length", type=float, default=1e-4,
101
+ help="floor for tip branch lengths in the species tree: identical sequences of two species give a "
102
+ "zero-length branch and a singular phylogenetic covariance matrix (default 1e-4 subst./site)")
103
+ ap.add_argument("--out_dir", required=True)
104
+ a = ap.parse_args(argv)
105
+ out = Path(a.out_dir); out.mkdir(parents=True, exist_ok=True)
106
+ root = midpoint_root(parse_newick(Path(a.tree).read_text()))
107
+ (out / "gene_tree_midpoint.nwk").write_text(to_newick(root) + ";\n")
108
+ S = pd.read_csv(a.specimens, sep="\t")
109
+ sp_of = dict(zip(S["sequence"], S["species"]))
110
+ tips = clade_tips(root)
111
+ # monophyly: the smallest clade holding all sequences of the species, and what else it holds
112
+ mono = []
113
+ for s, g in S.groupby("species"):
114
+ seqs = set(g["sequence"]) & tips
115
+ if len(seqs) < 2:
116
+ continue
117
+ best = None
118
+ st = [root]
119
+ while st:
120
+ n = st.pop()
121
+ ct = clade_tips(n)
122
+ if seqs <= ct and (best is None or len(ct) < len(best[1])):
123
+ best = (n, ct)
124
+ st.extend(n.kids)
125
+ others = sorted(sp_of.get(t, t) for t in best[1] - seqs)
126
+ mono.append({"species": s, "sequences": len(seqs), "monophyletic": not others,
127
+ "support": best[0].name if not others else "", "intruding": ", ".join(sorted(set(others)))})
128
+ M = pd.DataFrame(mono, columns=["species", "sequences", "monophyletic", "support", "intruding"]); M.to_csv(out / "morphospecies_monophyly.tsv", sep="\t", index=False)
129
+ # one tip per species
130
+ S["rank"] = [0 if (r.dna_agrees_with_label is True or r.dna_agrees_with_label == "True") and isinstance(r.matrix_named, str) and r.matrix_named
131
+ else 1 if (r.dna_agrees_with_label is True or r.dna_agrees_with_label == "True") else 2 for r in S.itertuples()]
132
+ keep = S[S["sequence"].isin(tips)].sort_values(["species", "rank", "sequence"]).groupby("species").head(1)
133
+ rep = dict(zip(keep["sequence"], keep["species"]))
134
+ sp_tree = prune(root, set(rep))
135
+ floored = []
136
+ for t in tips_of(sp_tree):
137
+ t.name = rep[t.name]
138
+ if t.length < a.min_tip_length:
139
+ floored.append(t.name); t.length = a.min_tip_length
140
+ _set_depths(sp_tree)
141
+ (out / "species_tree.nwk").write_text(to_newick(sp_tree, support=False) + ";\n")
142
+ keep[["species", "sequence", "specimen_id"]].to_csv(out / "species_tree_tips.tsv", sep="\t", index=False)
143
+ summ = {"gene_tree_tips": len(tips), "species_tips": len(rep), "tips_given_min_length": floored,
144
+ "morphospecies_tested": len(M), "monophyletic": int(M["monophyletic"].astype(bool).sum()),
145
+ "not_monophyletic": M.loc[~M["monophyletic"].astype(bool), ["species", "intruding"]].to_dict("records")}
146
+ json.dump(summ, open(out / "species_tree_summary.json", "w"), indent=2)
147
+ print(json.dumps(summ, indent=1))
148
+
149
+
150
+ if __name__ == "__main__":
151
+ main()
@@ -0,0 +1,346 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ descriptron_character_signal_v1.py - which characters separate species, and which carry phylogenetic signal?
4
+ ===========================================================================================================
5
+
6
+ For every character of the matrix (compiled_key_tier: specimen_matrix_long.csv + feature_dictionary.tsv):
7
+ species separation Kruskal-Wallis across species (species with >= 2 specimens), effect size
8
+ eta^2 = (H - k + 1) / (n - k), Benjamini-Hochberg q over all characters
9
+ phylogenetic signal with --tree: Blomberg's K of the species means (Adams' Kmult on one variable, as
10
+ geomorph::physignal), permutation P (--iterations), on the tree pruned to the species measured
11
+
12
+ Outputs (--out_dir):
13
+ character_signal_all.tsv every character
14
+ character_signal_top.tsv per structure, the --top characters with the largest eta^2 (all of them if the
15
+ structure has fewer)
16
+ character_signal_top.md the same as a readable table, one block per structure
17
+ character_phylo_signal.png/.pdf with --tree and --robustness: the companion to fig_character_robustness
18
+ (same family colours): a, every character's Blomberg's K by structure; b, naming against K
19
+ character_signal_volcano.png/.pdf only with --figure
20
+ a volcano plot: eta^2 against -log10 q, coloured by phylogenetic signal, the strongest characters labelled
21
+ b species separation against phylogenetic signal (eta^2 against K)
22
+ character_signal_summary.json
23
+
24
+ python descriptron_character_signal_v1.py --matrix_dir <out>/compiled_key_tier --tree tree.nwk --out_dir <out>/character_signal
25
+ """
26
+ import argparse
27
+ import json
28
+ import sys
29
+ from pathlib import Path
30
+
31
+ import numpy as np
32
+ import pandas as pd
33
+ import matplotlib
34
+ matplotlib.use("Agg")
35
+ import matplotlib.pyplot as plt
36
+ from scipy.stats import kruskal
37
+
38
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
39
+ import descriptron_phylo as dp # noqa: E402
40
+
41
+ INK, MUTED, GRID = "#1d2327", "#6b7780", "#d0d7de"
42
+ C_SIG, C_NS = "#0072B2", "#9aa5ad"
43
+ C_ROBUST, C_WEAK = "#0b57d0", "#7d878f" # label text: robust (named >= --robust_x times chance) / not
44
+
45
+
46
+ def bh(p):
47
+ p = np.asarray(p, float); q = np.full(len(p), np.nan); ok = ~np.isnan(p)
48
+ if ok.sum():
49
+ pp = p[ok]; o = np.argsort(pp); r = pp[o] * ok.sum() / np.arange(1, ok.sum() + 1)
50
+ r = np.minimum.accumulate(r[::-1])[::-1]; qq = np.empty_like(r); qq[o] = np.minimum(r, 1); q[ok] = qq
51
+ return q
52
+
53
+
54
+ def label_column(ax, xs, ys, labels, side_x=1.02, fontsize=5.6, gap_frac=1 / 34, colors=None):
55
+ """labels in a column right of the axes, spaced so they never overlap, each joined to its point by a thin line"""
56
+ if not len(xs):
57
+ return
58
+ lo, hi = ax.get_ylim(); gap = (hi - lo) * gap_frac
59
+ order = np.argsort(ys)[::-1]
60
+ pos, last = [], hi + gap
61
+ for i in order: # top-down, never closer than `gap`
62
+ y = min(ys[i], last - gap); pos.append((i, y)); last = y
63
+ shift = max(0.0, lo - min(y for _, y in pos)) # pushed below the axis: move the column up
64
+ to_axes = ax.transAxes.inverted()
65
+ for i, y in pos:
66
+ px, py = to_axes.transform(ax.transData.transform((xs[i], ys[i])))
67
+ _, ty = to_axes.transform(ax.transData.transform((xs[i], y + shift)))
68
+ # a leader line of (near) zero length breaks matplotlib's arrow drawing: no line when label sits on the point
69
+ line = abs(px - side_x) > 0.01 or abs(py - ty) > 0.01
70
+ ax.annotate(labels[i], (xs[i], ys[i]), xytext=(side_x, y + shift), textcoords=("axes fraction", "data"),
71
+ fontsize=fontsize, color=colors[i] if colors is not None else INK, va="center", annotation_clip=False,
72
+ arrowprops=dict(arrowstyle="-", color=MUTED, lw=0.4, shrinkA=0, shrinkB=2) if line else None)
73
+
74
+
75
+ def short_label(structure, label):
76
+ """the label alone when it already names its structure (e.g. antenna area), else structure: label"""
77
+ st = str(structure).lower().replace("_", " ")
78
+ first = str(label).split()[0].rstrip(":").lower() if str(label).split() else ""
79
+ return str(label) if first and (first in st or st.startswith(first)) else f"{structure}: {label}"
80
+
81
+
82
+ def blomberg_k_perm(y, C, iters, rng):
83
+ """Blomberg's K (= Kmult for one variable) and its permutation P, vectorised over permutations"""
84
+ n = len(y); Ci = np.linalg.inv(C); s1 = Ci.sum(); expct = (np.trace(C) - n / s1) / (n - 1)
85
+ Y = np.column_stack([y] + [y[rng.permutation(n)] for _ in range(iters)]) # n x (1 + iters)
86
+ a = (Ci.sum(0) @ Y) / s1
87
+ R = Y - a
88
+ obs = (R * R).sum(0) / (R * (Ci @ R)).sum(0)
89
+ K = obs / expct
90
+ return float(K[0]), float((1 + (K[1:] >= K[0] - 1e-12).sum()) / (iters + 1))
91
+
92
+
93
+ def _eta_figure(D, out, tree_txt, a):
94
+ D = D.copy(); D["mlq"] = -np.log10(D.q.clip(lower=1e-300))
95
+
96
+ sig = (D.K_P <= 0.05) if "K_P" in D else pd.Series(False, index=D.index)
97
+ fig, axs = plt.subplots(1, 2 if tree_txt else 1, figsize=(13.5 if tree_txt else 7.5, 4.8), squeeze=False,
98
+ gridspec_kw={"wspace": 1.0})
99
+ ax = axs[0, 0]
100
+ ax.scatter(D.eta2[~sig], D.mlq[~sig], s=9, color=C_NS, alpha=0.7, lw=0, label="no phylogenetic signal (P > 0.05)" if tree_txt else "character")
101
+ if tree_txt:
102
+ ax.scatter(D.eta2[sig], D.mlq[sig], s=12, color=C_SIG, lw=0, label="phylogenetic signal (P ≤ 0.05)")
103
+ ax.axhline(-np.log10(a.fdr), color=MUTED, lw=0.6, ls=":"); ax.text(0.01, -np.log10(a.fdr) + 0.1, f"q = {a.fdr}", fontsize=6.5, color=MUTED)
104
+ top = D.sort_values("eta2", ascending=False).head(a.label_top)
105
+ label_column(ax, top.eta2.values, top.mlq.values, list(top.label))
106
+ ax.set_xlabel("species separation (Kruskal–Wallis η²)", fontsize=8); ax.set_ylabel("−log10 q (FDR)", fontsize=8)
107
+ ax.set_title(f"a {len(D)} characters: separation and significance", loc="left", fontsize=9, weight="bold", color=INK)
108
+ ax.legend(fontsize=6.5, frameon=False, loc="upper left"); ax.tick_params(labelsize=7); ax.spines[["top", "right"]].set_visible(False)
109
+ if tree_txt:
110
+ ax = axs[0, 1]; E = D.dropna(subset=["K"])
111
+ s2 = E.K_P <= 0.05
112
+ ax.scatter(E.eta2[~s2], E.K[~s2], s=9, color=C_NS, alpha=0.7, lw=0)
113
+ ax.scatter(E.eta2[s2], E.K[s2], s=12, color=C_SIG, lw=0)
114
+ both = E[s2 & (E.q <= a.fdr)].sort_values("eta2", ascending=False).head(a.label_top)
115
+ label_column(ax, both.eta2.values, both.K.values, list(both.label))
116
+ ax.axhline(1, color=MUTED, lw=0.6, ls=":"); ax.text(0.99, 1.02, "K = 1 (Brownian motion)", fontsize=6.5, color=MUTED, ha="right", transform=ax.get_yaxis_transform())
117
+ ax.set_xlabel("species separation (Kruskal–Wallis η²)", fontsize=8); ax.set_ylabel("phylogenetic signal (Blomberg's K)", fontsize=8)
118
+ ax.set_title(f"b separation and phylogenetic signal ({int(s2.sum())} with P ≤ 0.05)", loc="left", fontsize=9, weight="bold", color=INK)
119
+ ax.tick_params(labelsize=7); ax.spines[["top", "right"]].set_visible(False)
120
+ for ext in ("png", "pdf"):
121
+ fig.savefig(out / f"character_signal_volcano.{ext}", dpi=300, facecolor="white", bbox_inches="tight")
122
+
123
+
124
+ def _robust_figure(A, out, tree_txt, a):
125
+ """a: hold-out naming against novelty AUC (the character-robustness plot); b: naming against Blomberg's K"""
126
+ D = A.dropna(subset=["top1", "novelty_auc"]).copy()
127
+ sig = (D.K_P <= 0.05) if "K_P" in D else pd.Series(False, index=D.index)
128
+ fig, axs = plt.subplots(1, 2 if tree_txt else 1, figsize=(13.5 if tree_txt else 7.5, 4.8), squeeze=False,
129
+ gridspec_kw={"wspace": 1.0})
130
+ ax = axs[0, 0]
131
+ ax.scatter(100 * D.top1[~sig], D.novelty_auc[~sig], s=9, color=C_NS, alpha=0.7, lw=0,
132
+ label="no phylogenetic signal (P > 0.05)" if tree_txt else "character")
133
+ if tree_txt:
134
+ ax.scatter(100 * D.top1[sig], D.novelty_auc[sig], s=12, color=C_SIG, lw=0, label="phylogenetic signal (P ≤ 0.05)")
135
+ ax.axhline(0.5, color=MUTED, lw=0.6, ls=":"); ax.text(0.99, 0.505, "AUC 0.5 = chance", fontsize=6.5, color=MUTED,
136
+ ha="right", transform=ax.get_yaxis_transform())
137
+ top = D.assign(sc=D.top1 + D.novelty_auc).sort_values("sc", ascending=False).head(a.label_top)
138
+ label_column(ax, 100 * top.top1.values, top.novelty_auc.values, [short_label(st, lb) for st, lb in zip(top.structure, top.label)])
139
+ ax.set_xlabel("names a withheld specimen (%, this character alone)", fontsize=8)
140
+ ax.set_ylabel("tells a described species from an unseen one (AUC)", fontsize=8)
141
+ ax.set_title(f"a {len(D)} characters: naming against novelty (hold-outs)", loc="left", fontsize=9, weight="bold", color=INK)
142
+ ax.legend(fontsize=6.5, frameon=False, loc="upper left"); ax.tick_params(labelsize=7); ax.spines[["top", "right"]].set_visible(False)
143
+ if tree_txt:
144
+ ax = axs[0, 1]; E = D.dropna(subset=["K"]); s2 = E.K_P <= 0.05
145
+ ax.scatter(100 * E.top1[~s2], E.K[~s2], s=9, color=C_NS, alpha=0.7, lw=0)
146
+ ax.scatter(100 * E.top1[s2], E.K[s2], s=12, color=C_SIG, lw=0)
147
+ both = E[s2].sort_values("top1", ascending=False).head(a.label_top)
148
+ label_column(ax, 100 * both.top1.values, both.K.values, [short_label(st, lb) for st, lb in zip(both.structure, both.label)])
149
+ ax.axhline(1, color=MUTED, lw=0.6, ls=":"); ax.text(0.99, 1.02, "K = 1 (Brownian motion)", fontsize=6.5, color=MUTED, ha="right", transform=ax.get_yaxis_transform())
150
+ ax.set_xlabel("names a withheld specimen (%, this character alone)", fontsize=8); ax.set_ylabel("phylogenetic signal (Blomberg's K)", fontsize=8)
151
+ ax.set_title(f"b naming and phylogenetic signal ({int(s2.sum())} with P ≤ 0.05)", loc="left", fontsize=9, weight="bold", color=INK)
152
+ ax.tick_params(labelsize=7); ax.spines[["top", "right"]].set_visible(False)
153
+ for ext in ("png", "pdf"):
154
+ fig.savefig(out / f"character_signal_volcano.{ext}", dpi=300, facecolor="white", bbox_inches="tight")
155
+
156
+
157
+ def _phylo_figure(A, out, a):
158
+ """companion to the character-robustness figure, same family colours: a, every character's phylogenetic signal per
159
+ structure; b, naming a withheld specimen against Blomberg's K. Filled = P <= 0.05, open = not."""
160
+ D = A.dropna(subset=["K", "top1"]).copy()
161
+ fams = sorted(A["family"].dropna().astype(str).unique()) # same order and palette as fig_character_robustness
162
+ pal = dict(zip(fams, ["#0072B2", "#E69F00", "#009E73", "#CC79A7", "#D55E00", "#000000", "#56B4E9", "#F0E442",
163
+ "#999999", "#7B3294"][:len(fams)] + ["#555555"] * max(0, len(fams) - 10)))
164
+ sig = D.K_P <= 0.05
165
+ share = D.groupby("structure").apply(lambda g: (g.K_P <= 0.05).mean())
166
+ order = share.sort_values().index.tolist()
167
+ fig, (ax, ax2) = plt.subplots(1, 2, figsize=(15, max(6, 0.3 * len(order) + 2)), gridspec_kw={"width_ratios": [1.2, 1], "wspace": 0.3})
168
+ ypos = {st: i for i, st in enumerate(order)}
169
+ jit = pd.Series(np.random.default_rng(0).uniform(-0.25, 0.25, len(D)), index=D.index)
170
+ for (f, filled), g in D.assign(_s=sig, _j=jit).groupby(["family", "_s"]):
171
+ ys = g.structure.map(ypos) + g._j
172
+ ax.scatter(g.K, ys, s=16 if filled else 12, facecolor=pal[str(f)] if filled else "none", edgecolor=pal[str(f)],
173
+ lw=0.7, alpha=0.9 if filled else 0.55, zorder=3 if filled else 2)
174
+ for st, i in ypos.items():
175
+ g = D[D.structure == st]
176
+ ax.text(1.0, i, f" {int((g.K_P <= 0.05).sum())}/{len(g)}", transform=ax.get_yaxis_transform(), va="center", fontsize=6, color="#546e7a")
177
+ ax.axvline(1, color="#b71c1c", lw=0.8, ls="--")
178
+ ax.set_yticks(range(len(order))); ax.set_yticklabels(order, fontsize=6.8)
179
+ ax.set_xlabel("phylogenetic signal of each character (Blomberg's K; 1 = Brownian motion)", fontsize=8)
180
+ ax.set_title("a Phylogenetic signal of every character, by structure\n(filled: P ≤ 0.05; ringed: P ≤ 0.05 and K > 1; "
181
+ "right: characters with signal / tested)",
182
+ loc="left", fontsize=9.5)
183
+ for f in fams:
184
+ ax.scatter([], [], s=16, color=pal[f], label=f)
185
+ ax.scatter([], [], s=14, facecolor="none", edgecolor="#555555", label="P > 0.05 (open)")
186
+ ax.legend(fontsize=6.5, frameon=False, loc="lower right", ncol=2)
187
+ for (f, filled), g in D.assign(_s=sig).groupby(["family", "_s"]):
188
+ ax2.scatter(100 * g.top1, g.K, s=16 if filled else 11, facecolor=pal[str(f)] if filled else "none", edgecolor=pal[str(f)],
189
+ lw=0.7, alpha=0.9 if filled else 0.5, zorder=3 if filled else 2)
190
+ ax2.axhline(1, color="#b71c1c", lw=0.8, ls="--")
191
+ # every character with signal (P <= 0.05) and K > 1: labelled inside the empty upper part, short labels
192
+ above = D[sig & (D.K > 1)].sort_values("K", ascending=False)
193
+ if len(above):
194
+ ax2.scatter(100 * above.top1, above.K, s=40, facecolor="none", edgecolor=INK, lw=0.6, zorder=4)
195
+ ax.scatter(above.K, above.structure.map(ypos) + jit.loc[above.index], s=40, facecolor="none", edgecolor=INK, lw=0.6, zorder=4)
196
+ short = [short_label(st, lb).replace("forewing: distance ", "").replace("distance ", "")
197
+ for st, lb in zip(above.structure, above.label)]
198
+ label_column(ax2, 100 * above.top1.values, above.K.values, short, side_x=0.56, fontsize=4.8, gap_frac=1 / 75,
199
+ colors=[C_ROBUST if x >= a.robust_x else C_WEAK for x in (above.top1 / above.chance_top1)])
200
+ lab = D[sig & ~D.index.isin(above.index)].assign(sc=D.top1 / D.chance_top1).sort_values("sc", ascending=False).head(a.label_top)
201
+ label_column(ax2, 100 * lab.top1.values, lab.K.values, [short_label(st, lb) for st, lb in zip(lab.structure, lab.label)],
202
+ colors=[C_ROBUST if x >= a.robust_x else C_WEAK for x in (lab.top1 / lab.chance_top1)])
203
+ ax2.set_xlabel("names a withheld specimen (%)", fontsize=8); ax2.set_ylabel("phylogenetic signal (Blomberg's K)", fontsize=8)
204
+ ax2.set_title(f"b All {len(D)} characters: naming against phylogenetic signal ({int(sig.sum())} with P ≤ 0.05)",
205
+ loc="left", fontsize=9.5)
206
+ # the key: which characters are labelled, and why (both sets require phylogenetic signal)
207
+ unl = D[~sig]
208
+ by_rel = unl.assign(sc=unl.top1 / unl.chance_top1).sort_values("sc", ascending=False).head(3)
209
+ by_raw = unl.sort_values("top1", ascending=False).head(3)
210
+ fmt = lambda g: "; ".join(f"{short_label(r.structure, r.label)} ({100 * r.top1:.0f}% named, chance {100 * r.chance_top1:.0f}%, "
211
+ f"K {r.K:.2f}, P {r.K_P:.2f})" for r in g.itertuples()) # noqa: E731
212
+ nrob = int(((D.top1 / D.chance_top1) >= a.robust_x).sum())
213
+ key = (f"Labels. Blue text: the character names withheld specimens at least {a.robust_x:g}× chance ({nrob} of {len(D)} "
214
+ "characters do); grey text: less.\n"
215
+ f" In the plot, ringed: every character with phylogenetic signal (P ≤ 0.05) and K > 1 ({len(above)}).\n"
216
+ f" Right column: the {len(lab)} other characters with signal that name withheld specimens best relative to chance\n"
217
+ " (% named ÷ % expected by chance, from the character-robustness analysis; fair across structures with few and many species).\n"
218
+ " Both sets require phylogenetic signal, so the most robust characters WITHOUT signal are not labelled:\n"
219
+ f" by % named (as ranked in the character-robustness figure): {fmt(by_raw)}\n"
220
+ f" by % named relative to chance: {fmt(by_rel)}\n"
221
+ "P: permutation test of phylogenetic signal (K larger than with species shuffled across the tree); it does not test K against 1.")
222
+ ax2.text(-1.25, -0.09, key, transform=ax2.transAxes, ha="left", va="top", fontsize=6.6, color=INK, linespacing=1.5)
223
+ for s_ in ("top", "right"):
224
+ ax.spines[s_].set_visible(False); ax2.spines[s_].set_visible(False)
225
+ for ext in ("png", "pdf"):
226
+ fig.savefig(out / f"character_phylo_signal.{ext}", dpi=300, facecolor="white", bbox_inches="tight")
227
+ plt.close(fig)
228
+
229
+
230
+ def main(argv=None):
231
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
232
+ ap.add_argument("--matrix_dir", required=True)
233
+ ap.add_argument("--tree", default=None, help="Newick tree, tips = species codes (adds phylogenetic signal)")
234
+ ap.add_argument("--robustness", default=None,
235
+ help="character_robustness.tsv from biorag_character_robustness_v1.py: rank characters by hold-out "
236
+ "naming (top-1) and novelty AUC instead of the in-sample eta^2 (recommended)")
237
+ ap.add_argument("--top", type=int, default=25, help="characters per structure in the table (default 25)")
238
+ ap.add_argument("--iterations", type=int, default=9999,
239
+ help="permutations for K (default 9999: with hundreds of characters, 999 cannot reach FDR q < 0.05)")
240
+ ap.add_argument("--min_species", type=int, default=4, help="species needed to test a character (default 4)")
241
+ ap.add_argument("--fdr", type=float, default=0.05)
242
+ ap.add_argument("--figure", action="store_true",
243
+ help="also draw character_signal_volcano.png/.pdf (off by default: the pipeline's character figure "
244
+ "is biorag_character_robustness_v1's fig_character_robustness)")
245
+ ap.add_argument("--label_top", type=int, default=12, help="characters labelled in the volcano plot")
246
+ ap.add_argument("--robust_x", type=float, default=3.0,
247
+ help="in the phylogenetic figure, labels of characters that name withheld specimens at least this many "
248
+ "times chance are drawn in blue (default 3)")
249
+ ap.add_argument("--seed", type=int, default=0)
250
+ ap.add_argument("--out_dir", required=True)
251
+ a = ap.parse_args(argv)
252
+ md = Path(a.matrix_dir); out = Path(a.out_dir); out.mkdir(parents=True, exist_ok=True)
253
+ L = pd.read_csv(md / "specimen_matrix_long.csv")
254
+ fd = pd.read_csv(md / "feature_dictionary.tsv", sep="\t").set_index("feature_id")
255
+ tree_txt = Path(a.tree).read_text() if a.tree else None
256
+ tips = {t.name for t in dp.tips_of(dp.parse_newick(tree_txt))} if tree_txt else set()
257
+ rng = np.random.default_rng(a.seed); ccache = {}; rows = []
258
+ for fid, g in L.groupby("feature_id"):
259
+ g = g.dropna(subset=["value"])
260
+ per = g.groupby("specimen_id").agg(species=("species", "first"), value=("value", "mean"))
261
+ cnt = per.species.value_counts(); multi = cnt[cnt >= 2].index
262
+ row = {"feature_id": fid, "structure": fd.loc[fid, "category"] if fid in fd.index else fid.split(".")[0],
263
+ "label": fd.loc[fid, "label"] if fid in fd.index else fid, "family": fd.loc[fid, "family"] if fid in fd.index else "",
264
+ "tier": fd.loc[fid, "tier"] if fid in fd.index else "", "specimens": len(per), "species": per.species.nunique()}
265
+ sub = per[per.species.isin(multi)]
266
+ if len(multi) >= max(2, a.min_species) and sub.value.nunique() > 1:
267
+ groups = [v.values for _, v in sub.groupby("species").value]
268
+ H, p = kruskal(*groups); n, k = len(sub), len(groups)
269
+ row.update(H=H, p=p, eta2=max(0.0, (H - k + 1) / (n - k)) if n > k else np.nan, kw_species=k)
270
+ if tree_txt:
271
+ means = per.groupby("species").value.mean()
272
+ sp = sorted(set(means.index) & tips)
273
+ if len(sp) >= a.min_species and means.loc[sp].nunique() > 1:
274
+ key = frozenset(sp)
275
+ if key not in ccache:
276
+ t = dp.prune(dp.parse_newick(tree_txt), set(sp)); tp = dp.tips_of(t)
277
+ ccache[key] = ([x.name for x in tp], dp.vcv(tp, tp))
278
+ order, C = ccache[key]
279
+ K, P = blomberg_k_perm(means.loc[order].values.astype(float), C, a.iterations, rng)
280
+ row.update(K=K, K_P=P, tree_species=len(order))
281
+ rows.append(row)
282
+ A = pd.DataFrame(rows)
283
+ A["q"] = bh(A.get("p", pd.Series(np.nan, index=A.index)).values)
284
+ if "K_P" in A:
285
+ A["K_q"] = bh(A["K_P"].values)
286
+ rob = a.robustness is not None
287
+ if rob:
288
+ Rb = pd.read_csv(a.robustness, sep="\t")
289
+ keep = ["feature_id", "top1", "top1_ci_low", "top1_ci_high", "chance_top1", "times_chance", "novelty_auc", "species_tested"]
290
+ A = A.merge(Rb[[c for c in keep if c in Rb.columns]], on="feature_id", how="left")
291
+ A = A.sort_values(["structure", "top1", "novelty_auc"], ascending=[True, False, False])
292
+ rank_col = "top1"
293
+ else:
294
+ A = A.sort_values(["structure", "eta2"], ascending=[True, False])
295
+ rank_col = "eta2"
296
+ A.to_csv(out / "character_signal_all.tsv", sep="\t", index=False)
297
+ T = A.dropna(subset=[rank_col]).groupby("structure", group_keys=False).head(a.top)
298
+ T.to_csv(out / "character_signal_top.tsv", sep="\t", index=False)
299
+
300
+ # readable table
301
+ how = ("hold-out naming (each specimen withheld; share whose own species is nearest on this character alone)"
302
+ if rob else "species separation (Kruskal-Wallis eta^2)")
303
+ lines = [f"# Most informative characters per structure (top {a.top} by {how})", "",
304
+ ("named: % of withheld specimens named (95% interval), chance in brackets; novelty AUC: tells a described "
305
+ "species from an unseen one (0.5 = no better than chance) - the measures drawn in the character-robustness "
306
+ "figure (fig_character_robustness); " if rob else "")
307
+ + "eta2: Kruskal-Wallis effect size across species; q: Benjamini-Hochberg over all characters"
308
+ + ("; K: Blomberg's K on the tree (P: permutation, " + str(a.iterations) + ")" if tree_txt else ""), ""]
309
+ for st, g in T.groupby("structure", sort=False):
310
+ lines += [f"## {st} ({len(g)} of {int((A.structure == st).sum())} characters)", "",
311
+ "| character | species |" + (" named % (95% CI) [chance] | novelty AUC |" if rob else "") + " eta2 | q |"
312
+ + (" K | P |" if tree_txt else ""),
313
+ "|---|---|" + ("---|---|" if rob else "") + "---|---|" + ("---|---|" if tree_txt else "")]
314
+ for r in g.itertuples():
315
+ s = f"| {r.label} | {r.species} |"
316
+ if rob:
317
+ s += (f" {100 * r.top1:.0f} ({100 * r.top1_ci_low:.0f}–{100 * r.top1_ci_high:.0f}) [{100 * r.chance_top1:.0f}] |"
318
+ f" {r.novelty_auc:.2f} |") if pd.notna(r.top1) else " - | - |"
319
+ s += f" {r.eta2:.2f} | {r.q:.1e} |" if pd.notna(r.eta2) else " - | - |"
320
+ if tree_txt:
321
+ s += f" {r.K:.2f} | {r.K_P:.3f} |" if pd.notna(getattr(r, "K", np.nan)) else " - | - |"
322
+ lines.append(s)
323
+ lines.append("")
324
+ (out / "character_signal_top.md").write_text("\n".join(lines))
325
+
326
+ # figure
327
+ if rob and tree_txt: # the phylogenetic companion to the character-robustness figure
328
+ _phylo_figure(A, out, a)
329
+ if a.figure and rob:
330
+ _robust_figure(A, out, tree_txt, a)
331
+ D = A.dropna(subset=["eta2", "q"]).copy()
332
+ if a.figure and not rob:
333
+ _eta_figure(D, out, tree_txt, a)
334
+ summ = {"characters": int(len(A)), "ranked_by": rank_col, "tested_for_separation": int(A.eta2.notna().sum()),
335
+ "separate_species_q_le_fdr": int((A.q <= a.fdr).sum()), "top_per_structure": a.top,
336
+ "structures": int(A.structure.nunique())}
337
+ if tree_txt:
338
+ summ.update(tested_for_signal=int(A.K.notna().sum()), signal_P_le_0_05=int((A.K_P <= 0.05).sum()),
339
+ signal_q_le_fdr=int((A.K_q <= a.fdr).sum()), iterations=a.iterations,
340
+ separate_and_signal=int(((A.q <= a.fdr) & (A.K_P <= 0.05)).sum()))
341
+ json.dump(summ, open(out / "character_signal_summary.json", "w"), indent=2)
342
+ print(json.dumps(summ))
343
+
344
+
345
+ if __name__ == "__main__":
346
+ main()