@@ -1224,11 +1449,11 @@ def _build_results(self) -> str:
return f"""
The flow matrix reveals the directed exchange patterns between organizational
units. Total System Throughput (TST) of
@@ -1276,9 +1501,9 @@ def _build_discussion(self) -> str:
return f"""
The OASIS assessment provides a multidimensional view of organizational health
grounded in network theory and information-theoretic principles. The overall
@@ -1286,10 +1511,10 @@ def _build_discussion(self) -> str:
the weighted balance across all five dimensions.
This analysis represents a point-in-time snapshot. Longitudinal analysis is
recommended to track organizational evolution. The meaning of flows (information,
@@ -1303,7 +1528,7 @@ def _build_references(self) -> str:
return f"""
Fath, B. D., Fiscus, D. A., Goerner, S. J., Berea, A., & Ulanowicz, R. E.
@@ -1364,17 +1589,17 @@ def _build_appendix(self) -> str:
return f"""
-
Appendix A: Scoring Weights
+
Appendix A: Scoring Weights
| Dimension | Metric | Weight |
{weight_rows}
- Table A1. Metric weights used in OASIS dimension scoring.
+ Metric weights used in OASIS dimension scoring.
-
Dimension Weight in Overall Score
+
Dimension Weight in Overall Score
| Dimension | Weight |
@@ -1386,7 +1611,34 @@ def _build_appendix(self) -> str:
| INTELLIGENT | {self.profile['weights'].get('intelligent', 0.20)*100:.0f}% |
| SUSTAINABLE | {self.profile['weights'].get('sustainable', 0.20)*100:.0f}% |
- Table A2. Dimension weights for overall OASIS score (default: equal weighting).
+ Dimension weights for overall OASIS score (default: equal weighting).
+
+ {self._build_glossary()}
+ """
+
+ def _build_glossary(self) -> str:
+ """Appendix B: glossary of core metrics (analyst reference)."""
+ glossary_terms = [
+ ('Total System Throughput (TST)', 'Sum of all flows; overall activity scale.'),
+ ('Average Mutual Information (AMI)', 'Average constraint/organization per unit flow.'),
+ ('Ascendency (A)', 'Organized power: TST × AMI.'),
+ ('Development Capacity (C)', 'Upper bound on ascendency: TST × flow diversity.'),
+ ('Overhead (Φ)', 'Reserve capacity C − A; supports resilience.'),
+ ('Relative Ascendency (α)', 'A / C; efficiency-vs-resilience balance.'),
+ ('Robustness (R)', '−α·ln(α); maximized near α ≈ 0.37.'),
+ ('Window of Viability', 'Empirical sustainable band α ∈ [0.2, 0.6].'),
+ ]
+ glossary_rows = "".join(
+ f"
| {t} | {d} |
"
+ for t, d in glossary_terms
+ )
+ return f"""
+
+
Appendix B: Metric Glossary
+
+ | Metric | Definition |
+ {glossary_rows}
+ Glossary of core metrics.
"""
@@ -1412,8 +1664,12 @@ def generate_html(self) -> str:
{self._build_cover_page()}
{self._build_executive_summary()}
+{self._build_benchmarking() if self.detailed else ""}
+{self._build_risk_resilience() if self.detailed else ""}
+{self._build_action_roadmap() if self.detailed else ""}
{self._build_methodology()}
{self._build_results()}
+{self._build_esg_mapping() if self.detailed else ""}
{self._build_discussion()}
{self._build_references()}
{self._build_appendix()}
@@ -1493,6 +1749,7 @@ def generate_oasis_pdf_report(
chart_images: Optional[Dict[str, bytes]] = None,
logo_path: Optional[str] = None,
output_path: Optional[str] = None,
+ detailed: bool = True,
) -> Optional[bytes]:
"""
Convenience function to generate a complete OASIS PDF report.
@@ -1522,6 +1779,7 @@ def generate_oasis_pdf_report(
recommendations=recommendations,
chart_images=chart_images,
logo_path=logo_path,
+ detailed=detailed,
)
if output_path:
diff --git a/src/pdf_generator.py b/src/pdf_generator.py
index d62302a..85f438c 100644
--- a/src/pdf_generator.py
+++ b/src/pdf_generator.py
@@ -12,6 +12,17 @@
import plotly.graph_objects as go
import plotly.io as pio
+
+def _pdf_gradient(alpha):
+ """Gradient classifier (position + direction-of-travel) โ single source of
+ truth from report_intelligence. Reframes the old binary viability verdict."""
+ try:
+ import report_intelligence as _ri
+ except ImportError: # pragma: no cover
+ from src import report_intelligence as _ri
+ return _ri.assess_alpha_position(alpha)
+
+
# โโ Color palette โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
FOREST_GREEN = '#1a5f35'
MEDIUM_GREEN = '#2d8a4e'
@@ -38,6 +49,75 @@
}
+# โโ Human-readable metric labels โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# Maps raw code identifiers (dict keys) to reader-facing labels. Used ONLY for
+# display text; dict keys/access are never changed.
+_METRIC_LABELS = {
+ 'relative_ascendency': 'relative ascendency (ฮฑ)',
+ 'ascendency_ratio': 'relative ascendency (ฮฑ)',
+ 'number_of_roles': 'number of functional roles',
+ 'functional_diversity': 'functional diversity',
+ 'finn_cycling_index': 'resource cycling (Finn cycling index)',
+ 'flow_reciprocity': 'flow reciprocity',
+ 'regenerative_capacity': 'regenerative capacity',
+ 'flow_diversity': 'flow diversity',
+ 'connectance': 'network connectance',
+ 'clustering_coefficient': 'clustering coefficient',
+ 'gini_coefficient': 'resource distribution (Gini coefficient)',
+ 'mutualism_ratio': 'mutualism ratio',
+ 'robustness': 'robustness',
+ 'redundancy': 'pathway redundancy',
+ 'overhead_ratio': 'reserve overhead',
+}
+
+
+def humanize_metric_name(name):
+ """Convert a raw metric identifier into a reader-facing label.
+
+ Falls back to a title-cased, underscore-stripped version for any key not in
+ the explicit map, guaranteeing no raw ``snake_case`` identifier reaches the
+ reader.
+ """
+ if not isinstance(name, str):
+ return str(name)
+ key = name.strip()
+ if key in _METRIC_LABELS:
+ return _METRIC_LABELS[key]
+ return key.replace('_', ' ').strip()
+
+
+def build_toc_items():
+ """Table-of-Contents entries mirroring the ACTUAL body headings, in order.
+
+ Kept in sync with :data:`BODY_HEADINGS`; every entry here must correspond to
+ a heading rendered in the report body.
+ """
+ return [
+ ("Executive Summary", ""),
+ ("1. Introduction", ""),
+ ("2. Methodology", ""),
+ ("3. Results", ""),
+ (" 3.1 Core Network Metrics", ""),
+ (" 3.2 Sustainability Assessment", ""),
+ (" 3.3 Visualizations", ""),
+ (" 3.4 Flow Distribution Analysis", ""),
+ ("4. OASIS Organizational Health Assessment", ""),
+ ("5. Benchmarking & Position", ""),
+ ("6. Risk & Resilience Analysis", ""),
+ ("7. Prioritized Action Roadmap", ""),
+ ("8. ESG Framework Mapping", ""),
+ ("9. Discussion", ""),
+ ("10. Conclusions & Recommendations", ""),
+ ("References", ""),
+ ("Appendix: Detailed Data", ""),
+ ]
+
+
+# Canonical list of body headings actually rendered (top-level + subsections),
+# used by the TOC and by proofing tests to guarantee TOCโbody consistency.
+BODY_HEADINGS = [t.strip() for t, _ in build_toc_items()]
+
+
def _hex_to_rgb(hex_color):
"""Convert hex color string to reportlab Color."""
from reportlab.lib.colors import HexColor
@@ -316,9 +396,61 @@ def _chart_image(fig, width=CONTENT_W, height=280):
width=render_w, height=render_h, scale=2)
img_buf = BytesIO(img_bytes)
return Image(img_buf, width=width, height=height)
- except Exception:
+ except Exception as _e:
+ import logging
+ logging.getLogger(__name__).warning(
+ "PDF chart (plotly/kaleido) export failed, skipping: %s", _e)
return None
+ def _mpl_image(fig, width=CONTENT_W, height=280, dpi=150):
+ """Convert a matplotlib Figure to a reportlab Image flowable.
+
+ Used for charts that already have a native matplotlib builder (e.g. the
+ Window-of-Viability curve) and as a kaleido-free fallback path.
+ """
+ try:
+ img_buf = BytesIO()
+ fig.savefig(img_buf, format='png', dpi=dpi,
+ bbox_inches='tight', facecolor='white')
+ img_buf.seek(0)
+ try:
+ import matplotlib.pyplot as _plt
+ _plt.close(fig)
+ except Exception:
+ pass
+ return Image(img_buf, width=width, height=height)
+ except Exception as _e:
+ import logging
+ logging.getLogger(__name__).warning(
+ "PDF chart (matplotlib) export failed, skipping: %s", _e)
+ return None
+
+ def _guarded_chart_block(builder, caption, story_list,
+ heading=None, heading_style=None):
+ """Build one chart via *builder* (returns a reportlab Image or None),
+ wrap it with a caption, and append as a KeepTogether block.
+
+ Each chart is individually guarded so one failure logs a warning and is
+ skipped rather than aborting the whole PDF. Returns True if embedded.
+ """
+ img = None
+ try:
+ img = builder()
+ except Exception as _e:
+ import logging
+ logging.getLogger(__name__).warning(
+ "PDF chart builder raised, skipping: %s", _e)
+ img = None
+ if img is None:
+ return False
+ block = []
+ if heading is not None:
+ block.append(Paragraph(heading, heading_style or s_h2))
+ block.append(img)
+ block.append(Paragraph(caption, s_caption))
+ story_list.append(KeepTogether(block))
+ return True
+
# โโ Build story โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
story = []
@@ -350,9 +482,10 @@ def _chart_image(fig, width=CONTENT_W, height=280):
story.append(Spacer(1, 2 * cm))
n_nodes = len(calculator.node_names)
n_edges = int(np.count_nonzero(calculator.flow_matrix))
- viability = 'Viable' if metrics['is_viable'] else 'Non-Viable'
+ viability = _pdf_gradient(metrics.get('relative_ascendency',
+ metrics.get('ascendency_ratio', 0)))['position']
cover_data = [
- ['Network Nodes', 'Active Connections', 'Viability Status', 'Robustness'],
+ ['Network Nodes', 'Active Connections', 'Gradient Position', 'Robustness'],
[str(n_nodes), str(n_edges), viability, f"{metrics.get('robustness', 0):.3f}"],
]
cover_table = Table(cover_data, colWidths=[CONTENT_W / 4] * 4)
@@ -375,16 +508,122 @@ def _chart_image(fig, width=CONTENT_W, height=280):
story.append(PageBreak())
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- # EXECUTIVE SUMMARY (KPI Cards)
+ # EXECUTIVE ONE-PAGER (R8 + R9)
+ # A self-contained, demo-ready first content page composed of five
+ # elements, in order:
+ # 0. The credibility keystone (R9) โ "Why this applies to your org".
+ # 1. Reconciled headline verdict โ the CAPPED OASIS status + capped_by.
+ # 2. KPI cards with reference anchors (gradient framing, never bare fail).
+ # 3. The marquee "you are here" Window-of-Viability curve.
+ # 4. Top-3 risks in Evidence -> Implication form.
+ # 5. Prioritized next steps (roadmap, time-horizoned).
+ # Then a clear "Detailed analysis follows" divider; the existing detailed
+ # sections continue after it.
+ # All inputs are the precomputed oasis_profile + report_intelligence views;
+ # nothing here recomputes a metric.
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ # Shared intelligence module + the precomputed profile (read, do not recompute).
+ try:
+ import report_intelligence as _ri_exec
+ except ImportError: # pragma: no cover
+ from src import report_intelligence as _ri_exec
+
+ _exec_profile = getattr(report_generator, 'oasis_profile', None)
+ if not (isinstance(_exec_profile, dict) and 'dimension_scores' in _exec_profile):
+ _exec_profile = None
+ if _exec_profile is None:
+ try:
+ from oasis_calculator import OASISCalculator as _OC_exec
+ except Exception:
+ try:
+ from src.oasis_calculator import OASISCalculator as _OC_exec
+ except Exception:
+ _OC_exec = None
+ if _OC_exec is not None:
+ try:
+ _exec_profile = _OC_exec(calculator).get_oasis_profile()
+ except Exception:
+ _exec_profile = None
+
+ rob = metrics.get('robustness', 0)
+ rob_status = _ri_exec.categorize_robustness_label(rob)
+ eff = metrics.get('network_efficiency', 0)
+ eff_status = _ri_exec.categorize_efficiency_label(eff)
+ alpha = metrics.get('ascendency_ratio', 0)
+ _grad_exec = _ri_exec.assess_alpha_position(alpha)
+
story.append(Paragraph("Executive Summary", s_h1))
story.append(HRFlowable(
width='100%', thickness=1, color=_hex_to_rgb(FOREST_GREEN),
- spaceBefore=0, spaceAfter=12,
+ spaceBefore=0, spaceAfter=10,
))
- # Build KPI cards as a table
- def _kpi_cell(label, value, status, color=None):
+ # โโ 0. Credibility keystone (R9): "Why this applies to your organization" โโ
+ # Lead with the ORGANIZATIONAL evidence (Fath 2019), not wetlands; frame
+ # the window as an indicative directional reference (honesty guardrail).
+ s_keystone = ParagraphStyle(
+ 'Keystone', parent=s_body, fontSize=9.5, leading=13,
+ textColor=_hex_to_rgb(DARK_TEXT),
+ leftIndent=6, rightIndent=6, spaceBefore=2, spaceAfter=8,
+ borderColor=_hex_to_rgb(TEAL), borderWidth=0.5, borderPadding=5,
+ backColor=_hex_to_rgb('#f4fbf9'),
+ )
+ story.append(Paragraph(
+ "
Why this applies to your organization. High-performing "
+ "organizations analyzed with this same efficiency–resilience "
+ "framework cluster in a characteristic range (relative ascendency "
+ "α ≈ 0.30–0.45; Fath et al., 2019, regenerative "
+ "economics). OASIS reads how your organization is
structurally "
+ "wired — the balance between coordinating efficiency and "
+ "adaptive reserve computed from real flow data — a network lens "
+ "that
complements, and does not replace, culture and engagement "
+ "measures. The viability band is an
indicative, directional "
+ "reference (calibrated on ecological systems; organizational "
+ "calibration is an open question), so read your position as a "
+ "direction of travel, not a compliance grade.",
+ s_keystone))
+
+ # โโ 1. Reconciled headline verdict โ capped status + business meaning โโ
+ if _exec_profile is not None:
+ _overall = float(_exec_profile.get('overall_score', 0.0))
+ _capped_status = str(_exec_profile.get('overall_status', 'UNKNOWN'))
+ _capped = bool(_exec_profile.get('overall_status_capped', False))
+ _capped_by = _exec_profile.get('capped_by', []) or []
+ _verdict_color = _get_status_color(_capped_status)
+ if _capped and _capped_by:
+ _cap_names = ', '.join(d.capitalize() for d in _capped_by)
+ _headline = (
+ f"
{_capped_status} "
+ f"— {_overall:.0f}/100, capped by a critical "
+ f"
{_cap_names} dimension."
+ )
+ _sowhat = (
+ "So what: the overall label is held below its raw average "
+ "because a core pillar is critical — a weak pillar cannot "
+ "be averaged away, and it sets the near-term priority."
+ )
+ else:
+ _headline = (
+ f"
{_capped_status} "
+ f"— {_overall:.0f}/100."
+ )
+ _sowhat = (
+ "So what: no single dimension is critical; the priority is to "
+ "hold the balance and address the weakest pillar before it "
+ "drifts."
+ )
+ _s_verdict = ParagraphStyle(
+ 'Verdict', parent=s_body, fontSize=13, leading=17,
+ spaceBefore=2, spaceAfter=2, alignment=TA_LEFT)
+ story.append(Paragraph("Headline Verdict", ParagraphStyle(
+ 'vh', parent=s_h3, spaceBefore=2, spaceAfter=2)))
+ story.append(Paragraph(_headline, _s_verdict))
+ story.append(Paragraph(_sowhat, ParagraphStyle(
+ 's', parent=s_body_italic, fontSize=9.5, leading=12, spaceAfter=8)))
+
+ # โโ 2. KPI cards with reference anchors (gradient framing) โโ
+ def _kpi_cell(label, value, status, anchor, color=None):
c = color or _get_status_color(status)
return [
Paragraph(str(value), ParagraphStyle(
@@ -392,30 +631,43 @@ def _kpi_cell(label, value, status, color=None):
Paragraph(label, s_kpi_label),
Paragraph(status, ParagraphStyle(
'ks', parent=s_kpi_status, textColor=_hex_to_rgb(c))),
+ Paragraph(anchor, ParagraphStyle(
+ 'ka', parent=s_kpi_label, fontSize=7.2, leading=8.5,
+ textColor=_hex_to_rgb(MUTED))),
]
- rob = metrics.get('robustness', 0)
- rob_status = 'High' if rob > 0.2 else 'Moderate' if rob > 0.15 else 'Low'
- eff = metrics.get('network_efficiency', 0)
- eff_status = 'Optimal' if 0.2 <= eff <= 0.6 else 'Sub-optimal'
- alpha = metrics.get('ascendency_ratio', 0)
+ # ฮฑ gradient position drives the alpha card's status word (never "Non-Viable").
+ _alpha_pos = {
+ 'under-organized': 'Under-organized',
+ 'over-organized': 'Over-organized',
+ 'balanced': 'Balanced',
+ }[_grad_exec['position']]
+ _overall_kpi = (f"{_exec_profile.get('overall_score', 0):.0f}"
+ if _exec_profile is not None else 'โ')
+ _overall_status_kpi = (str(_exec_profile.get('overall_status', ''))
+ if _exec_profile is not None else '')
kpi_cells = [
- _kpi_cell('Viability Status', viability, viability),
- _kpi_cell('Robustness (R)', f"{rob:.3f}", rob_status),
- _kpi_cell('Network Efficiency', f"{eff:.3f}", eff_status),
- _kpi_cell('Rel. Ascendency (ฮฑ)', f"{alpha:.3f}",
- 'Optimal' if 0.30 <= alpha <= 0.45 else 'Warning'),
+ _kpi_cell('OASIS Overall', _overall_kpi, _overall_status_kpi,
+ 'HEALTHY โฅ 60 / WARNING โฅ 40 / else CRITICAL'),
+ _kpi_cell('Rel. Ascendency (ฮฑ)', f"{alpha:.3f}", _alpha_pos,
+ 'indicative band 0.2โ0.6; high-perf. orgs 0.30โ0.45'),
+ _kpi_cell('Robustness (R)', f"{rob:.3f}", rob_status,
+ 'peaks โ0.37 (=1/e); High โฅ 0.25'),
+ _kpi_cell('Efficiency', f"{eff:.3f}", eff_status,
+ 'balanced 0.2โ0.6; high = brittle'),
]
- # Flatten into table rows (3 rows per card, 4 columns)
- kpi_data = [[cell[i] for cell in kpi_cells] for i in range(3)]
+ # Flatten into table rows (4 rows per card: value/label/status/anchor).
+ kpi_data = [[cell[i] for cell in kpi_cells] for i in range(4)]
kpi_table = Table(kpi_data, colWidths=[CONTENT_W / 4] * 4)
kpi_table.setStyle(TableStyle([
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
- ('TOPPADDING', (0, 0), (-1, -1), 6),
- ('BOTTOMPADDING', (0, 0), (-1, -1), 4),
+ ('TOPPADDING', (0, 0), (-1, -1), 4),
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 3),
+ ('LEFTPADDING', (0, 0), (-1, -1), 4),
+ ('RIGHTPADDING', (0, 0), (-1, -1), 4),
('BOX', (0, 0), (0, -1), 0.5, _hex_to_rgb('#e0e0e0')),
('BOX', (1, 0), (1, -1), 0.5, _hex_to_rgb('#e0e0e0')),
('BOX', (2, 0), (2, -1), 0.5, _hex_to_rgb('#e0e0e0')),
@@ -423,25 +675,108 @@ def _kpi_cell(label, value, status, color=None):
('BACKGROUND', (0, 0), (-1, -1), _hex_to_rgb('#fafcfb')),
]))
story.append(kpi_table)
- story.append(Spacer(1, 0.5 * cm))
-
- # Executive summary text
- exec_text = (
- f"This report presents a comprehensive network analysis of
{org_name} "
- f"using the Ulanowicz-Fath regenerative economics framework. The organization's "
- f"network comprises
{n_nodes} nodes and
{n_edges} directed connections, "
- f"with a total system throughput of
{metrics['total_system_throughput']:.1f} units."
- )
- story.append(Paragraph(exec_text, s_body))
-
- viab_text = (
- f"The system {'operates within' if metrics['is_viable'] else 'falls outside'} the "
- f"window of viability (ฮฑ = {alpha:.3f}, bounds: {metrics['viability_lower_bound']:.2f}โ"
- f"{metrics['viability_upper_bound']:.2f}), indicating "
- f"{'sustainable operational characteristics' if metrics['is_viable'] else 'need for structural adaptation'}. "
- f"Robustness of R = {rob:.3f} suggests {rob_status.lower()} resilience to perturbations."
- )
- story.append(Paragraph(viab_text, s_body))
+ story.append(Spacer(1, 0.35 * cm))
+
+ # โโ 3. The marquee "you are here" Window-of-Viability curve โโ
+ def _build_exec_wov_image():
+ try:
+ png = _ri_exec.render_window_of_viability_png(alpha, rob)
+ except Exception:
+ return None
+ if not png:
+ return None
+ try:
+ _w = CONTENT_W * 0.72
+ return Image(BytesIO(png), width=_w, height=_w * 4.0 / 7.2)
+ except Exception:
+ return None
+
+ _guarded_chart_block(
+ _build_exec_wov_image,
+ "
You are here. The organization's position (red marker) on the "
+ "robustness curve R(α) = −α·ln(α), with "
+ "the indicative reference band shaded. " + _grad_exec['caveat'],
+ story)
+
+ # โโ 4. Top-3 risks (Evidence -> Implication) โโ
+ story.append(Paragraph("Top Risks", ParagraphStyle(
+ 'trh', parent=s_h3, spaceBefore=4, spaceAfter=3)))
+ _exec_risks_rendered = False
+ if _exec_profile is not None:
+ try:
+ _risk_view = _ri_exec.build_risk_view(metrics, _exec_profile)
+ for _it in _risk_view['items'][:3]:
+ _sc = _get_status_color(_it['severity'])
+ story.append(Paragraph(
+ f"
{_it['severity']} "
+ f"— {_it['title']}. "
+ f"
Evidence: {_it['evidence']} "
+ f"
Implication: {_it['implication']}",
+ ParagraphStyle('rk', parent=s_body, fontSize=9, leading=11.5,
+ spaceBefore=1, spaceAfter=3,
+ leftIndent=10, firstLineIndent=-10)))
+ _exec_risks_rendered = True
+ except Exception:
+ _exec_risks_rendered = False
+ if not _exec_risks_rendered:
+ story.append(Paragraph(
+ "
Evidence: risk view unavailable for this network. "
+ "
Implication: see the detailed Risk & Resilience section.",
+ ParagraphStyle('rk0', parent=s_body, fontSize=9, leading=11.5,
+ spaceAfter=3)))
+
+ # โโ 5. Prioritized next steps (roadmap, time-horizoned) โโ
+ story.append(Paragraph("Prioritized Next Steps", ParagraphStyle(
+ 'nsh', parent=s_h3, spaceBefore=4, spaceAfter=3)))
+ _exec_steps_rendered = False
+ if _exec_profile is not None:
+ try:
+ try:
+ from oasis_calculator import OASISCalculator as _OC_rec
+ except Exception:
+ from src.oasis_calculator import OASISCalculator as _OC_rec
+ _exec_recs = _exec_profile.get('recommendations')
+ if _exec_recs is None:
+ _exec_recs = _OC_rec(calculator).get_recommendations()
+ _exec_roadmap = _ri_exec.build_action_roadmap(_exec_recs, _exec_profile)
+ _horizon_labels = [
+ ('immediate', 'Immediate (0โ3 mo)'),
+ ('short_term', 'Short-Term (3โ9 mo)'),
+ ('medium_term', 'Medium-Term (9โ18 mo)'),
+ ]
+ _flat_steps = []
+ for _hkey, _hlabel in _horizon_labels:
+ for _st in _exec_roadmap.get(_hkey, []):
+ _flat_steps.append((_hlabel, _st))
+ for _hlabel, _st in _flat_steps[:3]:
+ _pc = _get_status_color(_st.get('priority', ''))
+ story.append(Paragraph(
+ f"
{_hlabel} · "
+ f"
{_st.get('dimension', '')} — "
+ f"{_st.get('action', '')}",
+ ParagraphStyle('ns', parent=s_body, fontSize=9, leading=11.5,
+ spaceBefore=1, spaceAfter=3,
+ leftIndent=10, firstLineIndent=-10)))
+ _exec_steps_rendered = True
+ except Exception:
+ _exec_steps_rendered = False
+ if not _exec_steps_rendered:
+ story.append(Paragraph(
+ "
Immediate (0โ3 mo) · Establish a recurring "
+ "assessment cadence and confirm the reconciled verdict with "
+ "leadership before acting.",
+ ParagraphStyle('ns0', parent=s_body, fontSize=9, leading=11.5,
+ spaceAfter=3)))
+
+ # โโ Divider: analyst depth gated behind this line โโ
+ story.append(Spacer(1, 0.2 * cm))
+ story.append(HRFlowable(
+ width='100%', thickness=1.2, color=_hex_to_rgb(GOLD),
+ dash=(3, 2), spaceBefore=1, spaceAfter=3))
+ story.append(Paragraph(
+ "โ Detailed analysis follows โ",
+ ParagraphStyle('divider', parent=s_caption, fontSize=10,
+ textColor=_hex_to_rgb(MEDIUM_GREEN), spaceAfter=2)))
story.append(PageBreak())
@@ -453,23 +788,7 @@ def _kpi_cell(label, value, status, color=None):
width='100%', thickness=1, color=_hex_to_rgb(FOREST_GREEN),
spaceBefore=0, spaceAfter=16,
))
- toc_items = [
- ("Executive Summary", ""),
- ("1. Introduction", ""),
- ("2. Methodology", ""),
- ("3. Results", ""),
- (" 3.1 Network Structure", ""),
- (" 3.2 Information-Theoretic Analysis", ""),
- (" 3.3 System Organization", ""),
- (" 3.4 Sustainability Assessment", ""),
- (" 3.5 Resilience Metrics", ""),
- (" 3.6 Flow Distribution", ""),
- ("4. OASIS Health Assessment", ""),
- ("5. Discussion", ""),
- ("6. Conclusions & Recommendations", ""),
- ("References", ""),
- ("Appendix", ""),
- ]
+ toc_items = build_toc_items()
for item, _ in toc_items:
indent = 24 if item.startswith(' ') else 0
toc_style = ParagraphStyle(
@@ -744,12 +1063,12 @@ def _render_text_table(table_lines, story_list):
['Parameter', 'Value', 'Status'],
['Current Position (ฮฑ)', f"{alpha:.3f}",
'Optimal' if 0.30 <= alpha <= 0.45 else 'Developing' if alpha < 0.35 else 'Efficient'],
- ['Lower Bound', f"{metrics['viability_lower_bound']:.3f}",
- 'PASS' if alpha > metrics['viability_lower_bound'] else 'FAIL'],
- ['Upper Bound', f"{metrics['viability_upper_bound']:.3f}",
- 'PASS' if alpha < metrics['viability_upper_bound'] else 'FAIL'],
- ['Within Window of Viability', 'Yes' if metrics['is_viable'] else 'No',
- 'Sustainable' if metrics['is_viable'] else 'Needs attention'],
+ ['Reference Lower Edge', f"{metrics['viability_lower_bound']:.3f}",
+ 'above' if alpha > metrics['viability_lower_bound'] else 'below'],
+ ['Reference Upper Edge', f"{metrics['viability_upper_bound']:.3f}",
+ 'below' if alpha < metrics['viability_upper_bound'] else 'above'],
+ ['Gradient Position', _pdf_gradient(alpha)['position'],
+ 'Direction of travel: ' + _pdf_gradient(alpha)['direction_of_travel']],
]
viab_table_data = []
for ri, row in enumerate(viab_data):
@@ -770,42 +1089,103 @@ def _render_text_table(table_lines, story_list):
viab_t.setStyle(TableStyle(viab_style))
story.append(viab_t)
story.append(Paragraph(
- "
Table 2. Viability Assessment โ Position of the organization relative to the empirically derived window of viability, indicating whether current efficiency-resilience dynamics are sustainable.", s_caption))
+ "
Table 2. Gradient Position โ Position of the organization on the "
+ "efficiency-resilience gradient relative to the indicative reference band, "
+ "with direction of travel. " + _pdf_gradient(alpha)['caveat'], s_caption))
- # โโ Charts โโ
- # Professional figure caption mapping: chart_name -> interpretive note
+ # โโ Window-of-Viability / robustness curve (most important credibility
+ # visual). Prefer the native matplotlib builder; fall back to the
+ # Plotly robustness curve via kaleido if matplotlib is unavailable.
+ _wov_num = [0] # figure counter carried into 3.3
+
+ def _build_wov_image():
+ try:
+ from visualizer import SustainabilityVisualizer as _SV
+ except Exception:
+ from src.visualizer import SustainabilityVisualizer as _SV
+ try:
+ viz = _SV(calculator)
+ mpl_fig = viz.plot_sustainability_curve_matplotlib(figsize=(11, 4.5))
+ img = _mpl_image(mpl_fig, width=CONTENT_W * 0.95, height=CONTENT_W * 0.95 * 4.5 / 11)
+ if img is not None:
+ return img
+ # Fallback: plotly robustness curve through kaleido
+ return _chart_image(viz.create_robustness_curve(),
+ width=CONTENT_W * 0.9, height=250)
+ except Exception:
+ return None
+
+ if _guarded_chart_block(
+ _build_wov_image,
+ "
Figure 1. Window of Viability & Robustness Curve โ Left: the "
+ "organization's ascendency (A) versus development capacity (C) with the "
+ "green band marking the empirical window of viability. Right: key "
+ "sustainability metrics. This visual anchors the efficiency–resilience "
+ "trade-off central to the Ulanowicz-Fath framework.",
+ story):
+ _wov_num[0] = 1
+
+ # โโ 3.3 Visualizations โโ
+ # Self-sufficient: charts are built internally from the calculator so the
+ # report embeds real images even when the caller passes charts=None
+ # (previously this whole block was skipped -> zero images in the PDF).
+ # Any caller-supplied plotly figures are embedded in addition.
_figure_notes = {
"System Robustness Curve": "The organization's position (red marker) relative to the theoretical robustness function R = -ฮฑยทlog(ฮฑ), with the empirical optimum at ฮฑ โ 0.37.",
"Core Metrics Analysis": "Comparative bar chart of key information-theoretic indicators, enabling rapid identification of metrics that deviate from healthy-system benchmarks.",
"Flow Distribution": "Distribution of resource flows across the top network nodes, illustrating concentration patterns and potential structural dependencies.",
}
+
+ story.append(Paragraph("3.3 Visualizations", s_h2))
+ fig_num = [_wov_num[0]] # continue numbering after the WoV figure
+
+ # (a) Internal flow / Sankey diagram built from the calculator.
+ def _build_flow_image():
+ try:
+ from visualizer import SustainabilityVisualizer as _SV
+ except Exception:
+ from src.visualizer import SustainabilityVisualizer as _SV
+ viz = _SV(calculator)
+ return _chart_image(viz.create_sankey_diagram(),
+ width=CONTENT_W * 0.95, height=300)
+
+ fig_num[0] += 1
+ _flow_ok = _guarded_chart_block(
+ _build_flow_image,
+ f"
Figure {fig_num[0]}. Network Flow Diagram (Sankey) โ Directed "
+ "resource flows between nodes, revealing structural pathways, hubs and "
+ "dependencies across the organizational network.",
+ story)
+ if not _flow_ok:
+ fig_num[0] -= 1 # don't burn a figure number on a skipped chart
+
+ # (b) Any caller-supplied plotly figures (app path).
+ embedded_any = _flow_ok or _wov_num[0] > 0
if charts:
- fig_num = 1
- first_chart = True
for chart_name, fig in charts.items():
if fig is None:
continue
- img = _chart_image(fig, width=CONTENT_W * 0.92, height=250)
- if img:
- note = _figure_notes.get(chart_name, f"Visualization of {chart_name.lower()} for the analyzed network.")
- chart_block = [
- img,
- Paragraph(
- f"
Figure {fig_num}. {chart_name} โ {note}", s_caption),
- ]
- if first_chart:
- # Keep section heading with the first chart
- chart_block.insert(
- 0, Paragraph("3.3 Visualizations", s_h2))
- first_chart = False
- story.append(KeepTogether(chart_block))
- fig_num += 1
- if first_chart:
- # No valid charts โ still emit the heading
- story.append(Paragraph("3.3 Visualizations", s_h2))
- story.append(Paragraph(
- "Chart images could not be generated for this report.",
- s_body_italic))
+
+ def _build(_f=fig):
+ return _chart_image(_f, width=CONTENT_W * 0.92, height=250)
+
+ fig_num[0] += 1
+ note = _figure_notes.get(
+ chart_name,
+ f"Visualization of {chart_name.lower()} for the analyzed network.")
+ ok = _guarded_chart_block(
+ _build,
+ f"
Figure {fig_num[0]}. {chart_name} โ {note}",
+ story)
+ if ok:
+ embedded_any = True
+ else:
+ fig_num[0] -= 1
+
+ if not embedded_any:
+ story.append(Paragraph(
+ "Chart images could not be generated for this report.",
+ s_body_italic))
# โโ Remaining results text โโ
story.append(Paragraph("3.4 Flow Distribution Analysis", s_h2))
@@ -863,13 +1243,23 @@ def _render_text_table(table_lines, story_list):
spaceBefore=0, spaceAfter=8,
))
- # Try to get OASIS data
+ # Try to get OASIS data โ prefer the precomputed profile (computed once at
+ # provision) carried on the report_generator; recompute only on a miss.
try:
- from oasis_calculator import OASISCalculator
- oasis = OASISCalculator(calculator)
- profile = oasis.get_oasis_profile()
- interpretations = oasis.get_oasis_interpretation()
- recommendations = oasis.get_recommendations()
+ profile = getattr(report_generator, 'oasis_profile', None)
+ if not (isinstance(profile, dict) and 'dimension_scores' in profile):
+ profile = None
+ interpretations = profile.get('interpretation') if profile else None
+ recommendations = profile.get('recommendations') if profile else None
+ if profile is None or interpretations is None or recommendations is None:
+ from oasis_calculator import OASISCalculator
+ oasis = OASISCalculator(calculator)
+ if profile is None:
+ profile = oasis.get_oasis_profile()
+ if interpretations is None:
+ interpretations = oasis.get_oasis_interpretation()
+ if recommendations is None:
+ recommendations = oasis.get_recommendations()
scores = profile['dimension_scores']
overall = profile['overall_score']
@@ -882,6 +1272,19 @@ def _render_text_table(table_lines, story_list):
f"The overall health score is
{overall:.0f}/100 ({overall_status}).",
s_body))
+ # Transparency note: which weighting lens produced the overall. Equal
+ # weights are the honest default; a named profile is a modest context
+ # tilt (see docs/business-revision/evidence/expert-org-management.md ยง3).
+ _prof_weights = profile.get('weights') or {}
+ _is_equal = all(abs(w - 0.20) < 1e-6 for w in _prof_weights.values()) \
+ if _prof_weights else True
+ _lens = (profile.get('profile_name')
+ or ('Balanced (equal weights)' if _is_equal else 'Custom weights'))
+ story.append(Paragraph(
+ f"
Weighting profile: {_lens}. The overall is a weighted mean of the "
+ f"five dimension scores; equal weights (20% each) are the default lens.",
+ s_body))
+
# OASIS scores table
oasis_data = [['Dimension', 'Score', 'Status', 'Key Focus']]
dim_descriptions = {
@@ -925,6 +1328,39 @@ def _render_text_table(table_lines, story_list):
story.append(Paragraph(
"
Table 4. OASIS Organizational Health Profile โ Composite scores across the five OASIS dimensions (Open, Autonomous, Symbiotic, Intelligent, Sustainable), with status indicators benchmarked against healthy-system thresholds.", s_caption))
+ # โโ OASIS radar + dimension gauges (embedded images) โโ
+ try:
+ from oasis_visualizer import (
+ create_oasis_radar_chart as _radar,
+ create_all_dimension_gauges as _gauges)
+ except Exception:
+ try:
+ from src.oasis_visualizer import (
+ create_oasis_radar_chart as _radar,
+ create_all_dimension_gauges as _gauges)
+ except Exception:
+ _radar = _gauges = None
+
+ if _radar is not None:
+ _guarded_chart_block(
+ lambda: _chart_image(
+ _radar(scores, title="OASIS Health Profile"),
+ width=CONTENT_W * 0.7, height=CONTENT_W * 0.7),
+ "
Figure O1. OASIS Radar โ Five-dimension health profile "
+ "(Open, Autonomous, Symbiotic, Intelligent, Sustainable) plotted "
+ "against healthy-system thresholds. A balanced pentagon indicates "
+ "well-rounded organizational health.",
+ story)
+ if _gauges is not None:
+ _guarded_chart_block(
+ lambda: _chart_image(
+ _gauges(profile),
+ width=CONTENT_W * 0.98, height=200),
+ "
Figure O2. OASIS Dimension Gauges โ Per-dimension scores "
+ "(0–100) with color-coded status bands for at-a-glance "
+ "identification of strengths and weaknesses.",
+ story)
+
# Dimension interpretations
story.append(Paragraph("4.1 Dimension Interpretations", s_h2))
for dim in ['open', 'autonomous', 'symbiotic', 'intelligent', 'sustainable']:
@@ -951,8 +1387,10 @@ def _render_text_table(table_lines, story_list):
]
metrics_to_improve = rec.get('metrics_to_improve', [])
if metrics_to_improve:
+ _mnames = ', '.join(
+ humanize_metric_name(_m) for _m in metrics_to_improve)
rec_items.append(
- f"
Metrics to improve: {', '.join(metrics_to_improve)}")
+ f"
Metrics to improve: {_mnames}")
for item in rec_items:
story.append(Paragraph(f"โข {item}", ParagraphStyle(
'ri', parent=s_body, leftIndent=18, firstLineIndent=-12,
@@ -967,9 +1405,282 @@ def _render_text_table(table_lines, story_list):
story.append(PageBreak())
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- # 5. DISCUSSION
+ # 5-8. DETAILED ECOSYSTEMIC ANALYSIS
+ # (benchmarking, risk & resilience, action roadmap, ESG mapping)
+ # Built from src/report_intelligence.py on metrics already computed.
+ # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ _ri = None
+ try:
+ import report_intelligence as _ri
+ from oasis_calculator import OASISCalculator as _OC
+ except Exception:
+ try:
+ from src import report_intelligence as _ri
+ from src.oasis_calculator import OASISCalculator as _OC
+ except Exception:
+ _ri = None
+
+ if _ri is not None:
+ try:
+ # Prefer the precomputed OASIS profile carried on report_generator.
+ _profile = getattr(report_generator, 'oasis_profile', None)
+ if not (isinstance(_profile, dict) and 'dimension_scores' in _profile):
+ _profile = None
+ _recs = _profile.get('recommendations') if _profile else None
+ if _profile is None or _recs is None:
+ _oasis = _OC(calculator)
+ if _profile is None:
+ _profile = _oasis.get_oasis_profile()
+ if _recs is None:
+ _recs = _oasis.get_recommendations()
+ _bench = _ri.build_benchmark_view(metrics, _profile)
+ _risk = _ri.build_risk_view(metrics, _profile)
+ _roadmap = _ri.build_action_roadmap(_recs, _profile)
+ _esg = _ri.build_esg_crosswalk(_profile, metrics)
+
+ def _sec_rule():
+ story.append(HRFlowable(
+ width='100%', thickness=1, color=_hex_to_rgb(FOREST_GREEN),
+ spaceBefore=0, spaceAfter=8))
+
+ # ---- 5. Benchmarking & Position ----
+ story.append(Paragraph("5. Benchmarking & Position", s_h1))
+ _sec_rule()
+ _pos = {
+ 'within': 'within the Window of Viability',
+ 'above': 'above the viability band (tending rigid / over-organized)',
+ 'below': 'below the viability band (tending chaotic / under-organized)',
+ }.get(_bench['position'], 'undetermined')
+ story.append(Paragraph(
+ f"The organization's relative ascendency is "
+ f"
α = {_bench['alpha']:.3f}, placing it {_pos} "
+ f"(viable band {_bench['lower']}–{_bench['upper']}; robustness "
+ f"optimum α ≈ {_bench['optimum']:.2f}). Distance to the "
+ f"robustness optimum is
{_bench['distance_to_optimum']:.3f}.",
+ s_body))
+ # PEER-COHORT benchmark (percentile-vs-peers).
+ # HONEST: only reports a percentile when a size/sector-matched cohort
+ # of >= MIN_COHORT_SIZE peers exists in the store; otherwise renders
+ # an explicit insufficient-cohort note and falls back to the
+ # indicative reference below. Never fabricates peer numbers.
+ try:
+ try:
+ from database.peer_cohort import (
+ peer_alpha_benchmark as _pab,
+ format_peer_benchmark_note as _pbn,
+ )
+ from database.db_manager import get_database_manager as _gdb
+ except Exception:
+ from src.database.peer_cohort import (
+ peer_alpha_benchmark as _pab,
+ format_peer_benchmark_note as _pbn,
+ )
+ from src.database.db_manager import get_database_manager as _gdb
+ _peer_db = _gdb()
+ _org_nodes = len(calculator.node_names)
+ _org_sector = getattr(report_generator, 'sector', None)
+ _peer = _pab(_peer_db, alpha=_bench['alpha'],
+ node_count=_org_nodes, sector=_org_sector)
+ _peer_note = _pbn(_peer, _bench['alpha'])
+ _peer_style = s_body if _peer.get('status') == 'ok' else s_body_italic
+ story.append(Paragraph(
+ f"
Peer-cohort benchmark. {_peer_note}", _peer_style))
+ except Exception:
+ # Best-effort; on any failure fall through to the indicative
+ # reference without fabricating anything.
+ pass
+ # PRIMARY comparator: organizational anchor (Fath et al. 2019).
+ _org_lo, _org_hi = 0.30, 0.45
+ _org_in = _org_lo <= _bench['alpha'] <= _org_hi
+ story.append(Paragraph(
+ "
Primary benchmark — organizational reference. "
+ "High-performing organizations analyzed with this framework exhibit "
+ "relative ascendency α in the range "
+ f"
0.30–0.45 (Fath et al., 2019). At α = "
+ f"{_bench['alpha']:.3f}, {org_name} "
+ f"{'sits within' if _org_in else 'sits outside'} this "
+ "organizational band, which is the appropriate headline comparator "
+ "for interpreting these results.", s_body))
+ _org_data = [
+ ['Reference', 'Relative Ascendency (ฮฑ)', 'Source'],
+ [Paragraph('High-performing organizations', cell_b),
+ Paragraph('0.30โ0.45', cell_s),
+ Paragraph('Fath et al., 2019', cell_s)],
+ [Paragraph(f'
{org_name} (this assessment)', cell_b),
+ Paragraph(f"
{_bench['alpha']:.3f}", cell_s),
+ Paragraph('โ', cell_s)],
+ ]
+ _ot = Table(_org_data, colWidths=[
+ CONTENT_W * 0.40, CONTENT_W * 0.30, CONTENT_W * 0.30])
+ _ot.setStyle(TableStyle([
+ ('BACKGROUND', (0, 0), (-1, 0), _hex_to_rgb(TABLE_HEADER_BG)),
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
+ ('TOPPADDING', (0, 0), (-1, -1), 5),
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 5),
+ ('LEFTPADDING', (0, 0), (-1, -1), 6),
+ ('GRID', (0, 0), (-1, -1), 0.3, _hex_to_rgb('#cccccc')),
+ ('ROWBACKGROUNDS', (0, 1), (-1, -1),
+ [colors.white, _hex_to_rgb(TABLE_ALT_ROW)]),
+ ]))
+ story.append(_ot)
+ story.append(Paragraph(
+ "
Table 5. Primary benchmark — organizational reference "
+ "band (Fath et al., 2019).", s_caption))
+ # โโ Secondary: ecological anchors, clearly demoted to illustrative โโ
+ story.append(Spacer(1, 0.3 * cm))
+ story.append(Paragraph(
+ "The ecological values below are provided only as
illustrative "
+ "methodology reference points that calibrate the viability "
+ "scale. They are
not the benchmark for this organization and "
+ "should not be read as targets.", s_body_italic))
+ _anchor_data = [['Ecological Reference Point (illustrative)',
+ 'Relative Ascendency (ฮฑ)', 'Source']]
+ for _a in _bench['reference_anchors']:
+ _anchor_data.append([
+ Paragraph(_a['label'], cell_b),
+ Paragraph(f"{_a['relative_ascendency']:.3f}", cell_s),
+ Paragraph(_a.get('source', ''), cell_s)])
+ if len(_anchor_data) > 1:
+ _at = Table(_anchor_data, colWidths=[
+ CONTENT_W * 0.40, CONTENT_W * 0.30, CONTENT_W * 0.30])
+ _at.setStyle(TableStyle([
+ ('BACKGROUND', (0, 0), (-1, 0), _hex_to_rgb(MUTED)),
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
+ ('TOPPADDING', (0, 0), (-1, -1), 5),
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 5),
+ ('LEFTPADDING', (0, 0), (-1, -1), 6),
+ ('GRID', (0, 0), (-1, -1), 0.3, _hex_to_rgb('#cccccc')),
+ ('ROWBACKGROUNDS', (0, 1), (-1, -1),
+ [colors.white, _hex_to_rgb(TABLE_ALT_ROW)]),
+ ]))
+ story.append(_at)
+ story.append(Paragraph(
+ "
Table 5b. Ecological reference points (illustrative). "
+ "Scientific calibration values for the viability scale, "
+ "not organizational targets.", s_caption))
+ story.append(PageBreak())
+
+ # ---- 6. Risk & Resilience Analysis ----
+ story.append(Paragraph("6. Risk & Resilience Analysis", s_h1))
+ _sec_rule()
+ story.append(Paragraph(
+ f"Overall fragility classification:
{_risk['fragility']}. "
+ f"Adaptive reserve indicators — overhead ratio "
+ f"{_risk['overhead_ratio'] * 100:.1f}%, redundancy "
+ f"{_risk['redundancy']:.3f}.", s_body))
+ for _it in _risk['items']:
+ _sc = _get_status_color(_it['severity'])
+ story.append(Paragraph(
+ f"
{_it['severity']} "
+ f"— {_it['title']}", s_h3))
+ story.append(Paragraph(f"
Evidence: {_it['evidence']}", s_body))
+ story.append(Paragraph(
+ f"
Implication: {_it['implication']}", s_body))
+ story.append(PageBreak())
+
+ # ---- 7. Prioritized Action Roadmap ----
+ story.append(Paragraph("7. Prioritized Action Roadmap", s_h1))
+ _sec_rule()
+ for _htitle, _hkey in [
+ ('7.1 Immediate (0โ3 months)', 'immediate'),
+ ('7.2 Short-Term (3โ9 months)', 'short_term'),
+ ('7.3 Medium-Term (9โ18 months)', 'medium_term'),
+ ]:
+ story.append(Paragraph(_htitle, s_h2))
+ _items = _roadmap[_hkey]
+ if not _items:
+ story.append(Paragraph(
+ "No actions in this horizon.", s_body_italic))
+ continue
+ for _it in _items:
+ _pc = _get_status_color(_it['priority'])
+ story.append(Paragraph(
+ f"
{_it['priority']} "
+ f"· {_it['dimension']}", s_h3))
+ story.append(Paragraph(f"
Issue: {_it['issue']}", s_body))
+ story.append(Paragraph(f"
Action: {_it['action']}", s_body))
+ story.append(Paragraph(
+ f"
Expected impact: {_it['expected_impact']}", s_body))
+ _m = ', '.join(
+ humanize_metric_name(_x)
+ for _x in _it['metrics_to_improve']) or 'N/A'
+ story.append(Paragraph(
+ f"
Metrics to improve: {_m}", s_body))
+ story.append(PageBreak())
+
+ # ---- 8. ESG Framework Mapping ----
+ story.append(Paragraph("8. ESG Framework Mapping", s_h1))
+ _sec_rule()
+ # Single source of truth for the caveat (report_intelligence).
+ _esg_caveat = getattr(
+ _ri, 'INDICATIVE_ESG_CAVEAT',
+ "Indicative structural-lens crosswalk โ not a compliance attestation.")
+ story.append(Paragraph(f"
{_esg_caveat}", s_body_italic))
+ story.append(Paragraph(
+ "The mapping is
finding-specific: framework codes are shown at the "
+ "granularity we can defend (series/pillar), the relevance note states what "
+ "the structural finding
informs, and the materiality flag reflects "
+ "this organization's actual dimension status. Analogue mappings (e.g. the "
+ "climate-scoped TCFD pillars against non-climate structural findings) are "
+ "explicitly marked
contextual, never as direct disclosures.", s_body))
+ story.append(Spacer(1, 0.2 * cm))
+
+ _materiality_color = {
+ 'attention': '#c0392b', 'watch': '#d4a843',
+ 'supporting': '#1a5f35', 'not_assessed': MUTED,
+ }
+ for _row in _esg:
+ _mat = _row['materiality']
+ _mc = _materiality_color.get(_mat['flag'], MUTED)
+ story.append(Paragraph(
+ f"
{_row['oasis_dimension']} — {_row['construct']}", s_h3))
+ story.append(Paragraph(
+ f"
Finding: {_row['finding_summary']}", s_body))
+ # Framework codes (with contextual caveats) as a compact table.
+ _fw_data = [['Standard', 'Reference', 'Disclosure area / caveat']]
+ for _fw in _row['frameworks']:
+ _lbl = _fw.get('label', '')
+ if _fw.get('contextual') and _fw.get('caveat'):
+ _lbl = (f"{_lbl}
[contextual: "
+ f"{_fw['caveat']}]")
+ _fw_data.append([
+ Paragraph(f"
{_fw['standard']}", cell_s),
+ Paragraph(_fw['code'], cell_s),
+ Paragraph(_lbl, cell_s)])
+ _ft = Table(_fw_data, colWidths=[
+ CONTENT_W * 0.12, CONTENT_W * 0.22, CONTENT_W * 0.66])
+ _ft.setStyle(TableStyle([
+ ('BACKGROUND', (0, 0), (-1, 0), _hex_to_rgb(TABLE_HEADER_BG)),
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
+ ('VALIGN', (0, 0), (-1, -1), 'TOP'),
+ ('TOPPADDING', (0, 0), (-1, -1), 4),
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 4),
+ ('LEFTPADDING', (0, 0), (-1, -1), 6),
+ ('GRID', (0, 0), (-1, -1), 0.3, _hex_to_rgb('#cccccc')),
+ ('ROWBACKGROUNDS', (0, 1), (-1, -1),
+ [colors.white, _hex_to_rgb(TABLE_ALT_ROW)]),
+ ]))
+ story.append(_ft)
+ story.append(Paragraph(
+ f"
Disclosure relevance: {_row['disclosure_relevance']}", s_body))
+ story.append(Paragraph(
+ f"
Materiality (this organization): "
+ f"
{_mat['label']}", s_body))
+ story.append(Spacer(1, 0.25 * cm))
+ story.append(Paragraph(
+ "
Table 6. Finding-specific, status-driven OASIS-to-ESG structural-lens "
+ "crosswalk (indicative; not a compliance attestation).", s_caption))
+ story.append(PageBreak())
+ except Exception:
+ # Detailed analysis is additive; never break the base report.
+ pass
+
+ # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ # 9. DISCUSSION
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- story.append(Paragraph("5. Discussion", s_h1))
+ story.append(Paragraph("9. Discussion", s_h1))
story.append(HRFlowable(
width='100%', thickness=1, color=_hex_to_rgb(FOREST_GREEN),
spaceBefore=0, spaceAfter=8,
@@ -980,7 +1691,7 @@ def _render_text_table(table_lines, story_list):
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# 6. CONCLUSIONS & RECOMMENDATIONS
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- story.append(Paragraph("6. Conclusions & Recommendations", s_h1))
+ story.append(Paragraph("10. Conclusions & Recommendations", s_h1))
story.append(HRFlowable(
width='100%', thickness=1, color=_hex_to_rgb(FOREST_GREEN),
spaceBefore=0, spaceAfter=8,
diff --git a/src/publication_report.py b/src/publication_report.py
index 5198989..196823b 100644
--- a/src/publication_report.py
+++ b/src/publication_report.py
@@ -11,6 +11,39 @@
import json
+def _viability_bands():
+ """Import the report_intelligence module holding the single-source-of-truth
+ viability / efficiency / robustness band constants (E-19, E-20)."""
+ try:
+ import report_intelligence as _ri
+ except ImportError: # pragma: no cover - package-path fallback
+ from src import report_intelligence as _ri
+ return _ri
+
+
+def flow_diversity_utilization(flow_diversity: float, n_nodes: int) -> float:
+ """Flow-diversity utilization as a percentage of the theoretical maximum.
+
+ The maximum flow diversity for a network with n nodes (n^2 possible directed
+ flow cells) is H_max = log(n^2). The utilization is fd / H_max * 100.
+
+ IMPORTANT (base match): the Ulanowicz engine computes flow_diversity in NATS
+ (natural log). The denominator MUST use the SAME base, i.e. np.log(n**2)
+ (nats) -- NOT np.log2(n**2) (bits). The previous code mixed a nats numerator
+ with a base-2 denominator, understating utilization by a factor of ln(2) ~=
+ 0.693 (a uniform-flow network read ~69% instead of 100%). See
+ validation-EF-network-stats.md (S5).
+
+ Guards n <= 1 (H_max = log(1) = 0) to avoid divide-by-zero.
+ """
+ if n_nodes is None or n_nodes <= 1:
+ return 0.0
+ h_max = np.log(n_nodes ** 2) # nats, matching flow_diversity's base
+ if h_max <= 0:
+ return 0.0
+ return float(flow_diversity / h_max * 100)
+
+
class PublicationReportGenerator:
"""
Generates professional, audit-firm quality reports for organizational
@@ -20,15 +53,83 @@ class PublicationReportGenerator:
"""
def __init__(self, calculator, metrics: Dict[str, Any], assessments: Dict[str, str],
- org_name: str, flow_matrix: np.ndarray, node_names: List[str]):
- """Initialize report generator with analysis data."""
+ org_name: str, flow_matrix: np.ndarray, node_names: List[str],
+ oasis_profile: Dict[str, Any] = None):
+ """Initialize report generator with analysis data.
+
+ Args:
+ oasis_profile: optional precomputed OASIS profile (from the full
+ profile computed once at provision). When provided, the OASIS
+ section READS it instead of recomputing via OASISCalculator.
+ """
self.calculator = calculator
- self.metrics = metrics
+ # Copy so we can safely backfill without mutating the caller's dict.
+ self.metrics = dict(metrics) if isinstance(metrics, dict) else {}
self.assessments = assessments
self.org_name = org_name
self.flow_matrix = flow_matrix
self.node_names = node_names
+ self.oasis_profile = oasis_profile
self.timestamp = datetime.now()
+ # Robustness: the report bracket-accesses many keys. Depending on the
+ # entry path (fresh get_extended_metrics vs. cache-reconstructed tier-2
+ # metrics) some keys are absent, which previously raised KeyError, and
+ # some may carry sentinel strings ('insufficient') or None which crash
+ # ':.Nf' formatting. Normalize once so every downstream access is safe.
+ self._ensure_metric_defaults()
+
+ def _ensure_metric_defaults(self) -> None:
+ """Backfill missing/sentinel metric keys with sensible numeric defaults.
+
+ Does NOT change any formula: present numeric values are untouched. Only
+ absent keys, None, or sentinel strings ('insufficient', 'skipped_*',
+ 'not_computed_*') are replaced so the narrative's f-string formatting
+ (e.g. ``{v:.3f}``) never raises.
+ """
+ m = self.metrics
+ ri = _viability_bands()
+
+ # Indicative reference band edges (single source of truth: E-19/E-20).
+ m.setdefault('viability_lower_bound', getattr(ri, 'VIABILITY_LOWER', 0.2))
+ m.setdefault('viability_upper_bound', getattr(ri, 'VIABILITY_UPPER', 0.6))
+
+ # alpha alias parity (report uses ascendency_ratio; pipeline uses
+ # relative_ascendency).
+ if m.get('ascendency_ratio') is None:
+ m['ascendency_ratio'] = m.get('relative_ascendency', 0.0)
+ if m.get('relative_ascendency') is None:
+ m['relative_ascendency'] = m.get('ascendency_ratio', 0.0)
+
+ alpha = m.get('ascendency_ratio', 0.0)
+ try:
+ alpha = float(alpha)
+ except (TypeError, ValueError):
+ alpha = 0.0
+ if 'is_viable' not in m or not isinstance(m.get('is_viable'), (bool, int, float)):
+ m['is_viable'] = bool(
+ m['viability_lower_bound'] <= alpha <= m['viability_upper_bound'])
+
+ # Numeric metrics the narrative formats with ':.Nf'. Coerce any missing
+ # key / None / sentinel string to a float default so formatting is safe.
+ _numeric_defaults = {
+ 'robustness': 0.0, 'redundancy': 0.0, 'overhead': 0.0,
+ 'overhead_ratio': 0.0, 'network_efficiency': 0.0, 'ascendency': 0.0,
+ 'development_capacity': 0.0, 'trophic_depth': 0.0,
+ 'effective_link_density': 0.0, 'flow_diversity': 0.0,
+ 'regenerative_capacity': 0.0, 'structural_information': 0.0,
+ 'total_system_throughput': 0.0, 'average_mutual_information': 0.0,
+ 'reserve': 0.0, 'reserve_ratio': 0.0, 'connectance': 0.0,
+ }
+ for key, default in _numeric_defaults.items():
+ val = m.get(key, default)
+ if isinstance(val, bool) or not isinstance(val, (int, float)):
+ # None, str sentinels ('insufficient', 'skipped_large_graph'),
+ # numpy strings, etc. -> fall back to the numeric default.
+ try:
+ val = float(val)
+ except (TypeError, ValueError):
+ val = default
+ m[key] = val
# ==================================================================
# Public report sections
@@ -37,25 +138,31 @@ def __init__(self, calculator, metrics: Dict[str, Any], assessments: Dict[str, s
def generate_abstract(self) -> str:
"""Generate a two-paragraph executive abstract."""
- alpha = self.metrics['ascendency_ratio']
- rob = self.metrics['robustness']
- viable = self.metrics['is_viable']
+ alpha = self.metrics.get('ascendency_ratio', self.metrics.get('relative_ascendency', 0))
+ rob = self.metrics.get('robustness', 0)
+ lower = self.metrics.get('viability_lower_bound_alpha', 0.2)
+ upper = self.metrics.get('viability_upper_bound_alpha', 0.6)
+ # Absolute (capacity-unit) bounds are stored under viability_lower/upper_bound; the
+ # indicative band on the alpha scale is [0.2, 0.6]. Present the alpha band.
+ in_band = bool(self.metrics.get('is_viable', lower <= alpha <= upper))
n_nodes = len(self.node_names)
n_edges = np.count_nonzero(self.flow_matrix)
tst = np.sum(self.flow_matrix)
+ overhead_ratio = self.metrics.get('overhead_ratio', 0)
- viability_word = "within" if viable else "outside"
- sustainability_clause = (
- "sustainable operational characteristics consistent with long-term adaptive capacity"
- if viable else
- "structural conditions that warrant management attention and targeted intervention"
- )
+ if alpha < lower:
+ position, direction = "under-organized", "increase structure / coordination"
+ elif alpha > upper:
+ position, direction = "over-organized", "increase redundancy / flexibility"
+ else:
+ position, direction = "balanced", "maintain balance"
+ band_word = "within" if in_band else "outside"
abstract = f"""
ABSTRACT
========
-{self.org_name} {"demonstrates" if viable else "presents"} a network whose relative ascendency of alpha = {alpha:.3f} places it {viability_word} the empirically derived window of viability ({self.metrics['viability_lower_bound']:.2f} < alpha < {self.metrics['viability_upper_bound']:.2f}), indicating {sustainability_clause}. This assessment applies the Ulanowicz-Fath regenerative economics framework to a directed network of {n_nodes} organizational units connected through {n_edges} active flow relationships, representing a total system throughput of {tst:.1f} units. The system achieves a robustness of R = {rob:.3f} ({self._categorize_robustness().lower()}) and utilizes {alpha * 100:.1f}% of its development capacity for organized behavior while retaining {self.metrics['overhead_ratio'] * 100:.1f}% as overhead reserves for adaptability.
+{self.org_name} presents a network whose relative ascendency of alpha = {alpha:.3f} places it {band_word} the indicative reference band ({lower:.2f} < alpha < {upper:.2f}) on the efficiency/resilience gradient โ a {position} position (direction of travel: {direction}). The reference band is derived from ecological systems and is an indicative directional reference for organizations, not a compliance threshold. This assessment applies the Ulanowicz-Fath regenerative economics framework to a directed network of {n_nodes} organizational units connected through {n_edges} active flow relationships, representing a total system throughput of {tst:.1f} units. The system achieves a robustness of R = {rob:.3f} ({self._categorize_robustness().lower()}) and utilizes {alpha * 100:.1f}% of its development capacity for organized behavior while retaining {overhead_ratio * 100:.1f}% as overhead reserves for adaptability.
The analysis reveals {self._categorize_efficiency().lower()} network efficiency and {"a hierarchically layered" if self.metrics.get('trophic_depth', 0) > 2 else "a relatively flat"} information flow architecture with an effective link density of {self.metrics.get('effective_link_density', 0):.3f} and trophic depth of {self.metrics.get('trophic_depth', 0):.3f}. A flow diversity index of H = {self.metrics.get('flow_diversity', 0):.3f} bits indicates {"substantial" if self.metrics.get('flow_diversity', 0) > 3 else "moderate" if self.metrics.get('flow_diversity', 0) > 2 else "limited"} information distribution complexity. These quantitative findings provide an evidence base for strategic decisions regarding organizational design, resilience investment, and sustainable growth.
"""
@@ -115,7 +222,7 @@ def generate_methodology(self) -> str:
Ascendency (A) quantifies how much of the network's activity is organized into purposeful, constrained pathways. Development Capacity (C) represents the theoretical maximum organization the network could achieve. The ratio alpha = A / C expresses current organization as a fraction of potential. Overhead (Phi = C - A) measures the reserve capacity available for adaptation, learning, and recovery from disruption.
2.2.3 Sustainability Position
-The Window of Viability defines the range of alpha values associated with sustainable system dynamics, empirically bounded at 0.20 (minimum coherence) and 0.60 (maximum efficiency before brittleness onset). Robustness (R = -alpha x log2(alpha)) peaks at alpha approximately equal to 0.37, the theoretical optimum for balancing order with flexibility.
+The indicative reference band spans the range of alpha values empirically associated with sustainable system dynamics in ecological networks, bounded at 0.20 (minimum coherence) and 0.60 (maximum efficiency before brittleness onset). Robustness (R = -alpha x log2(alpha)) peaks at alpha approximately equal to 0.37. """ + _viability_bands().INDICATIVE_REFERENCE_CAVEAT + """
2.2.4 Network Architecture
Effective Link Density measures the proportion of possible connections that are active. Trophic Depth indicates how many hierarchical levels information traverses. Redundancy captures the availability of alternative pathways should primary channels be disrupted. Regenerative Capacity integrates overhead with proximity to the optimal balance point.
@@ -154,26 +261,32 @@ def generate_results(self) -> str:
cv_flow = std_flow / mean_flow if mean_flow > 0 else 0
gini = self._calculate_gini()
- # Viability interpretation
- if viable:
+ # Viability interpretation โ reframed as position-on-a-gradient +
+ # direction-of-travel against the INDICATIVE ecological reference band.
+ _ri = _viability_bands()
+ _grad = _ri.assess_alpha_position(alpha)
+ if _grad['position'] == 'balanced':
viability_narrative = (
- f"The organization operates within the window of viability, confirming that its "
- f"current configuration balances constraining efficiency with adaptive flexibility "
- f"in a manner consistent with long-term sustainability."
+ f"On the efficiency/resilience gradient the organization sits within the "
+ f"indicative reference band (alpha = {alpha:.3f}), a configuration that "
+ f"balances constraining efficiency with adaptive flexibility. "
+ f"Direction of travel: {_grad['direction_of_travel']}. {_ri.INDICATIVE_REFERENCE_CAVEAT}"
)
- elif alpha < self.metrics['viability_lower_bound']:
+ elif _grad['position'] == 'under-organized':
viability_narrative = (
- f"The organization falls below the lower bound of the window of viability "
- f"(alpha = {alpha:.3f} vs. threshold of {self.metrics['viability_lower_bound']:.2f}), "
- f"indicating insufficient organizational coherence. Flows are dispersed across "
- f"too many weakly constrained pathways, reducing collective effectiveness."
+ f"On the efficiency/resilience gradient the organization reads as under-organized "
+ f"relative to the indicative reference band (alpha = {alpha:.3f} vs. the reference "
+ f"lower edge of {self.metrics['viability_lower_bound']:.2f}). Flows are dispersed "
+ f"across many weakly constrained pathways. Direction of travel: "
+ f"{_grad['direction_of_travel']}. {_ri.INDICATIVE_REFERENCE_CAVEAT}"
)
else:
viability_narrative = (
- f"The organization exceeds the upper bound of the window of viability "
- f"(alpha = {alpha:.3f} vs. threshold of {self.metrics['viability_upper_bound']:.2f}), "
- f"indicating over-constraint. Flows are concentrated through too few dominant "
- f"pathways, leaving insufficient reserves for adaptation and recovery."
+ f"On the efficiency/resilience gradient the organization reads as over-organized "
+ f"relative to the indicative reference band (alpha = {alpha:.3f} vs. the reference "
+ f"upper edge of {self.metrics['viability_upper_bound']:.2f}). Flows are concentrated "
+ f"through a few dominant pathways, leaving thinner reserves for adaptation. "
+ f"Direction of travel: {_grad['direction_of_travel']}. {_ri.INDICATIVE_REFERENCE_CAVEAT}"
)
# Robustness distance from optimum
@@ -210,13 +323,15 @@ def generate_results(self) -> str:
The relative ascendency of alpha = {alpha:.3f} indicates that {self.org_name} channels {alpha * 100:.1f}% of its development capacity into organized, purposeful flows. This positions the system as {self._interpret_position().lower()} on the organization spectrum. The viability assessment is summarized below.
-Table 1. Viability Assessment Summary
---------------------------------------
-Parameter Value Status
+Table 1. Gradient Position Summary (vs. indicative reference band)
+------------------------------------------------------------------
+Parameter Value Position / Note
Relative Ascendency (alpha) {alpha:<11.3f} {self._interpret_position()}
-Window Lower Bound {self.metrics['viability_lower_bound']:<11.3f} {'PASS' if alpha > self.metrics['viability_lower_bound'] else 'FAIL'}
-Window Upper Bound {self.metrics['viability_upper_bound']:<11.3f} {'PASS' if alpha < self.metrics['viability_upper_bound'] else 'FAIL'}
-Within Window of Viability {'Yes':<11} {self._get_viability_interpretation()}
+Reference Lower Edge {self.metrics['viability_lower_bound']:<11.3f} {'above' if alpha > self.metrics['viability_lower_bound'] else 'below'} lower edge
+Reference Upper Edge {self.metrics['viability_upper_bound']:<11.3f} {'below' if alpha < self.metrics['viability_upper_bound'] else 'above'} upper edge
+Gradient Position {'':<11} {self._get_viability_interpretation()}
+
+Note: {_viability_bands().INDICATIVE_REFERENCE_CAVEAT}
3.2 Efficiency-Resilience Balance
----------------------------------
@@ -263,21 +378,25 @@ def generate_discussion(self) -> str:
td = self.metrics['trophic_depth']
fd = self.metrics['flow_diversity']
ovh_ratio = self.metrics['overhead_ratio']
- h_max = np.log2(len(self.node_names) ** 2)
- fd_utilization = (fd / h_max * 100) if h_max > 0 else 0
+ # Utilization: fd (nats) vs H_max = log(n^2) in the SAME base (nats).
+ fd_utilization = flow_diversity_utilization(fd, len(self.node_names))
# Strengths and risks
strengths = []
risks = []
- if viable:
+ _ri_sr = _viability_bands()
+ _grad_sr = _ri_sr.assess_alpha_position(alpha)
+ if _grad_sr['position'] == 'balanced':
strengths.append(
- f"viability positioning (alpha = {alpha:.3f} within the sustainable window)"
+ f"gradient position (alpha = {alpha:.3f} within the indicative reference band)"
)
else:
risks.append(
- f"viability positioning (alpha = {alpha:.3f} outside the window bounds of "
- f"{self.metrics['viability_lower_bound']:.2f} to {self.metrics['viability_upper_bound']:.2f})"
+ f"gradient position (alpha = {alpha:.3f} reads {_grad_sr['position']} relative to "
+ f"the indicative reference band {self.metrics['viability_lower_bound']:.2f}"
+ f"โ{self.metrics['viability_upper_bound']:.2f}; direction of travel: "
+ f"{_grad_sr['direction_of_travel']})"
)
if rob > 0.20:
@@ -300,10 +419,10 @@ def generate_discussion(self) -> str:
a_phi_ratio = self.metrics['ascendency'] / self.metrics['overhead'] if self.metrics['overhead'] > 0 else 0
discussion = f"""
-4. DISCUSSION
+9. DISCUSSION
=============
-4.1 Strategic Assessment
+9.1 Strategic Assessment
-------------------------
The analysis of {self.org_name} yields a clear overall picture: the organization {"maintains a configuration consistent with sustainable dynamics" if viable else "exhibits structural conditions that require deliberate intervention"}.
@@ -316,7 +435,7 @@ def generate_discussion(self) -> str:
if risks:
discussion += f"""The material risks warranting management attention include {'; '.join(risks)}. {"Left unaddressed, these conditions could erode the organization's capacity to respond to environmental changes or absorb operational shocks." if not viable else "While these do not currently threaten viability, monitoring is warranted to ensure they do not deteriorate."}\n\n"""
- discussion += f"""4.2 Comparative Positioning
+ discussion += f"""9.2 Comparative Positioning
----------------------------
Empirical benchmarks from ecological and organizational literature provide useful context. Sustainable ecological food webs typically exhibit alpha in the range 0.20 to 0.50 (Ulanowicz, 2009). High-performing organizations analyzed using the same framework show alpha between 0.30 and 0.45 (Fath et al., 2019). The current system's alpha of {alpha:.3f} {"aligns with" if 0.30 <= alpha <= 0.45 else "deviates from"} the high-performing organizational benchmark.
@@ -324,7 +443,7 @@ def generate_discussion(self) -> str:
The flow diversity utilization -- the ratio of observed diversity to the theoretical maximum -- stands at {fd_utilization:.1f}%. This indicates that {self.org_name} employs {"a broad range" if fd_utilization > 50 else "a limited fraction"} of its potential communication channels, {"supporting distributed knowledge flow" if fd_utilization > 50 else "suggesting opportunity to broaden information pathways"}.
-4.3 Limitations and Caveats
+9.3 Limitations and Caveats
-----------------------------
Several limitations should be considered when interpreting these findings.
@@ -340,20 +459,21 @@ def generate_conclusions(self) -> str:
viable = self.metrics['is_viable']
eff = self.metrics['network_efficiency']
ovh_ratio = self.metrics['overhead_ratio']
+ _grad_c = _viability_bands().assess_alpha_position(alpha)
conclusions = f"""
-5. CONCLUSIONS AND RECOMMENDATIONS
+10. CONCLUSIONS AND RECOMMENDATIONS
===================================
-5.1 Summary of Findings
+10.1 Summary of Findings
-------------------------
-This assessment of {self.org_name} establishes that the organization {"operates within the window of viability, demonstrating" if viable else "falls outside the window of viability, lacking"} the efficiency-resilience balance associated with sustainable network dynamics. Robustness of R = {rob:.3f} ({self._categorize_robustness().lower()}) and network efficiency of {eff:.3f} ({self._categorize_efficiency().lower()}) together characterize a system that {"is well-positioned for sustained performance in dynamic conditions" if viable and rob > 0.2 else "maintains adequate but not exceptional adaptive capacity" if viable else "requires structural adjustment to restore sustainable dynamics"}.
+This assessment of {self.org_name} places the organization on the efficiency-resilience gradient as {_grad_c['position']} relative to the indicative reference band, with direction of travel: {_grad_c['direction_of_travel']}. Robustness of R = {rob:.3f} ({self._categorize_robustness().lower()}) and network efficiency of {eff:.3f} ({self._categorize_efficiency().lower()}) together characterize a system that {"is well-positioned for sustained performance in dynamic conditions" if _grad_c['position'] == 'balanced' and rob > 0.2 else "maintains adequate but not exceptional adaptive capacity" if _grad_c['position'] == 'balanced' else "would move toward the indicative band by " + _grad_c['direction_of_travel']}. {_viability_bands().INDICATIVE_REFERENCE_CAVEAT}
-5.2 Prioritized Recommendations
+10.2 Prioritized Recommendations
---------------------------------
{self._generate_priority_recommendations()}
-5.3 Future Assessment
+10.3 Future Assessment
----------------------
To strengthen the evidence base, the following extensions are recommended: longitudinal tracking of these metrics at regular intervals to identify trends and cycles; comparative benchmarking against industry peers using the same framework; separate analysis of distinct flow types (information, resources, authority) where data permits; and dynamic modeling to develop predictive scenarios for organizational evolution.
@@ -429,7 +549,7 @@ def generate_appendix(self) -> str:
Trophic Depth: Average path length weighted by flow magnitude
-Network Efficiency: A / (C x log2(n))
+Network Efficiency: alpha = A / C
Regenerative Capacity: (Phi / C) x (1 - |alpha - 0.37|)
@@ -506,11 +626,21 @@ def generate_oasis_section(self) -> str:
"""Generate OASIS Organizational Health Assessment section with narrative interpretation."""
try:
- from oasis_calculator import OASISCalculator
- oasis = OASISCalculator(self.calculator)
- profile = oasis.get_oasis_profile()
- interpretations = oasis.get_oasis_interpretation()
- recommendations = oasis.get_recommendations()
+ # Prefer the precomputed OASIS profile (computed once at provision).
+ profile = self.oasis_profile if isinstance(self.oasis_profile, dict) \
+ and 'dimension_scores' in self.oasis_profile else None
+ if profile is not None:
+ interpretations = profile.get('interpretation')
+ recommendations = profile.get('recommendations')
+ if profile is None or interpretations is None or recommendations is None:
+ from oasis_calculator import OASISCalculator
+ oasis = OASISCalculator(self.calculator)
+ if profile is None:
+ profile = oasis.get_oasis_profile()
+ if interpretations is None:
+ interpretations = oasis.get_oasis_interpretation()
+ if recommendations is None:
+ recommendations = oasis.get_recommendations()
except Exception as e:
return f"""
6. OASIS ORGANIZATIONAL HEALTH ASSESSMENT
@@ -640,44 +770,22 @@ def generate_full_report(self) -> str:
# ==================================================================
def _categorize_efficiency(self) -> str:
- """Categorize network efficiency level."""
+ """Categorize network efficiency (alpha = A/C) using the viability-anchored
+ single-source-of-truth bands (E-19). HIGH efficiency reads as
+ over-organized/brittle (NOT "good"), consistent with the risk framing."""
eff = self.metrics['network_efficiency']
- if eff < 0.2:
- return "Low"
- elif eff < 0.4:
- return "Moderate"
- elif eff < 0.6:
- return "High"
- else:
- return "Very High"
+ return _viability_bands().categorize_efficiency_label(eff)
def _categorize_robustness(self) -> str:
- """Categorize robustness level."""
+ """Categorize robustness using the shared threshold constant (E-20)."""
rob = self.metrics['robustness']
- if rob < 0.1:
- return "Very Low"
- elif rob < 0.15:
- return "Low"
- elif rob < 0.2:
- return "Moderate"
- elif rob < 0.25:
- return "High"
- else:
- return "Very High"
+ return _viability_bands().categorize_robustness_label(rob)
def _interpret_position(self) -> str:
- """Interpret position in window of viability."""
+ """Interpret alpha's position in the window of viability. Shares the
+ single-source-of-truth efficiency bands (E-19)."""
alpha = self.metrics['ascendency_ratio']
- if alpha < 0.2:
- return "Under-organized"
- elif alpha < 0.35:
- return "Developing"
- elif alpha < 0.45:
- return "Optimal"
- elif alpha < 0.6:
- return "Efficient"
- else:
- return "Over-constrained"
+ return _viability_bands().categorize_efficiency_label(alpha)
def _calculate_gini(self) -> float:
"""Calculate Gini coefficient for flow distribution."""
@@ -694,13 +802,14 @@ def _calculate_gini(self) -> float:
# ==================================================================
def _get_viability_interpretation(self) -> str:
- """Get interpretation of viability status."""
- if self.metrics['is_viable']:
- return "Sustainable"
- elif self.metrics['ascendency_ratio'] < self.metrics['viability_lower_bound']:
- return "Too chaotic"
- else:
- return "Too rigid"
+ """Gradient position vs. the indicative reference band (not pass/fail)."""
+ grad = _viability_bands().assess_alpha_position(
+ self.metrics['ascendency_ratio'])
+ return {
+ 'balanced': 'within indicative band',
+ 'under-organized': 'under-organized (below band)',
+ 'over-organized': 'over-organized (above band)',
+ }[grad['position']]
def _interpret_redundancy(self) -> str:
"""Interpret redundancy level."""
diff --git a/src/report_intelligence.py b/src/report_intelligence.py
new file mode 100644
index 0000000..a2b4a8c
--- /dev/null
+++ b/src/report_intelligence.py
@@ -0,0 +1,659 @@
+"""
+Report intelligence: deterministic synthesis of OASIS profile + Ulanowicz metrics
+into structured content for the detailed report.
+
+IMPORTANT: This module contains NO scientific formulas. It classifies, sequences,
+and looks up values that are already computed elsewhere. The only constants are the
+Window-of-Viability bounds and the robustness optimum, which are existing codebase
+constants (Ulanowicz; alpha = 1/e maximizes R = -alpha*ln(alpha)).
+"""
+from typing import Any, Dict, List
+
+# Window of Viability โ existing engine constants (Ulanowicz et al. 2009)
+VIABILITY_LOWER = 0.2
+VIABILITY_UPPER = 0.6
+ROBUSTNESS_OPTIMUM = 0.367879441 # 1/e, where R = -alpha*ln(alpha) is maximal
+
+# ---------------------------------------------------------------------------
+# INDICATIVE-REFERENCE CAVEAT โ single source of truth for the framing note
+# ---------------------------------------------------------------------------
+# The [0.2, 0.6] band and its 1/e optimum are ECOLOGICAL reference points. Their
+# transfer to organizational networks is NOT established (Fath 2019: organizational
+# networks are more redundant and sit elsewhere on the curve; org calibration is an
+# open question). We therefore present the band as an *indicative directional
+# reference*, never as an absolute organizational pass/fail threshold.
+INDICATIVE_REFERENCE_CAVEAT = (
+ "Reference band derived from ecological systems; organizational calibration "
+ "is an active area โ read this as a directional indicator, not a compliance "
+ "threshold."
+)
+# The center of the indicative reference band, used only as a neutral gradient
+# anchor for direction-of-travel (NOT a target). This is the midpoint of the
+# existing bounds, introducing no new threshold constant.
+_INDICATIVE_BAND_CENTER = (VIABILITY_LOWER + VIABILITY_UPPER) / 2.0 # 0.4
+
+
+def assess_alpha_position(alpha: float) -> Dict[str, Any]:
+ """
+ Gradient classifier for relative ascendency (alpha) against the INDICATIVE
+ ecological reference band [VIABILITY_LOWER, VIABILITY_UPPER].
+
+ This is the single source of truth for reframing the old binary
+ "Viable / Non-Viable (PASS/FAIL)" verdict into a *position-on-a-gradient*
+ with a *direction-of-travel*. It introduces NO new threshold constants and
+ changes NO score formula โ it only classifies an already-computed alpha
+ relative to the existing bounds, framed as an indicative reference.
+
+ Returns a dict with:
+ - position: 'under-organized' (alpha < VIABILITY_LOWER),
+ 'balanced' (VIABILITY_LOWER <= alpha <= VIABILITY_UPPER),
+ 'over-organized' (alpha > VIABILITY_UPPER).
+ - direction_of_travel: plain-language nudge back toward balance.
+ - descriptor: short plain-English phrase describing the position relative
+ to the indicative reference band.
+ - relative_distance: signed gradient value. Negative = below the lower
+ edge (by how much); positive = above the upper edge; when
+ inside the band it is the signed offset from the band center
+ (negative = below center, positive = above center). This is a
+ gradient, NOT a pass/fail flag.
+ - lower / upper / center: the indicative reference band bounds/center.
+ - caveat: the indicative-reference caveat string.
+ """
+ alpha = float(alpha)
+
+ if alpha < VIABILITY_LOWER:
+ position = 'under-organized'
+ direction = 'increase structure / coordination'
+ descriptor = ('diffuse / under-structured relative to the indicative '
+ 'reference band')
+ # signed: how far *below* the lower edge (negative)
+ relative_distance = alpha - VIABILITY_LOWER
+ elif alpha > VIABILITY_UPPER:
+ position = 'over-organized'
+ direction = 'increase redundancy / flexibility'
+ descriptor = ('highly streamlined / over-structured relative to the '
+ 'indicative reference band')
+ # signed: how far *above* the upper edge (positive)
+ relative_distance = alpha - VIABILITY_UPPER
+ else:
+ position = 'balanced'
+ direction = 'maintain balance'
+ descriptor = ('within the indicative reference band '
+ '(balanced structure and flexibility)')
+ # signed offset from the band center (a gradient, not a verdict)
+ relative_distance = alpha - _INDICATIVE_BAND_CENTER
+
+ return {
+ 'alpha': alpha,
+ 'position': position,
+ 'direction_of_travel': direction,
+ 'descriptor': descriptor,
+ 'relative_distance': relative_distance,
+ 'lower': VIABILITY_LOWER,
+ 'upper': VIABILITY_UPPER,
+ 'center': _INDICATIVE_BAND_CENTER,
+ 'caveat': INDICATIVE_REFERENCE_CAVEAT,
+ }
+
+# ---------------------------------------------------------------------------
+# SINGLE SOURCE OF TRUTH โ efficiency (alpha) interpretation bands (E-19 fix)
+# ---------------------------------------------------------------------------
+# The efficiency label of alpha (= network_efficiency = A/C) MUST agree with the
+# Window-of-Viability risk framing. Under that model, HIGH efficiency is NOT
+# "good" โ it is over-organized / brittle. The interior sub-bands (0.35, 0.45)
+# split the in-window range into developing / optimal / efficient.
+# alpha < 0.2 : under-organized / chaotic (below window)
+# 0.2 <= alpha < 0.35 : developing
+# 0.35 <= alpha < 0.45 : optimal (near 1/e robustness peak)
+# 0.45 <= alpha < 0.6 : efficient (watch for rigidity)
+# alpha >= 0.6 : over-organized / brittle (above window)
+EFFICIENCY_BAND_LOWER = VIABILITY_LOWER # 0.2 โ below = under-organized
+EFFICIENCY_BAND_DEVELOPING = 0.35 # 0.35 โ developing -> optimal
+EFFICIENCY_BAND_OPTIMAL = 0.45 # 0.45 โ optimal -> efficient
+EFFICIENCY_BAND_UPPER = VIABILITY_UPPER # 0.6 โ above = over-organized/brittle
+
+# ---------------------------------------------------------------------------
+# SINGLE SOURCE OF TRUTH โ robustness "high" threshold (E-20 fix)
+# ---------------------------------------------------------------------------
+# The lower rung 0.2 was already shared across paths; the "high" cutoff differed
+# (0.20 on the PDF path, 0.25 on LaTeX/CLI). Unified to 0.25 so R = 0.22 no
+# longer flips verdict by export type. Documented choice: 0.25 is the more
+# conservative rung and matches the LaTeX/CLI narrative already in production.
+ROBUSTNESS_HIGH_THRESHOLD = 0.25
+
+
+def categorize_efficiency_label(alpha: float) -> str:
+ """
+ Viability-anchored efficiency label for alpha (= network_efficiency = A/C).
+
+ Single source of truth for the E-19 efficiency labels. HIGH efficiency is
+ framed as over-organized/brittle (NOT "good"), consistent with the risk view.
+ """
+ if alpha < EFFICIENCY_BAND_LOWER:
+ return "Under-organized"
+ elif alpha < EFFICIENCY_BAND_DEVELOPING:
+ return "Developing"
+ elif alpha < EFFICIENCY_BAND_OPTIMAL:
+ return "Optimal"
+ elif alpha < EFFICIENCY_BAND_UPPER:
+ return "Efficient"
+ else:
+ return "Over-organized"
+
+
+def categorize_robustness_label(robustness: float) -> str:
+ """
+ Single source of truth for robustness labels (E-20). The "high" rung uses
+ ROBUSTNESS_HIGH_THRESHOLD (0.25) consistently across all report paths.
+ """
+ if robustness < 0.1:
+ return "Very Low"
+ elif robustness < 0.15:
+ return "Low"
+ elif robustness < VIABILITY_LOWER: # 0.2
+ return "Moderate"
+ elif robustness < ROBUSTNESS_HIGH_THRESHOLD: # 0.25
+ return "High"
+ else:
+ return "Very High"
+
+
+def _alpha(profile: Dict[str, Any], metrics: Dict[str, Any] = None) -> float:
+ """Read relative ascendency (alpha) from metrics, falling back to profile."""
+ if metrics and 'ascendency_ratio' in metrics:
+ return float(metrics.get('ascendency_ratio', 0.0))
+ return float(profile.get('dimension_details', {})
+ .get('sustainable', {}).get('metrics', {})
+ .get('relative_ascendency', 0.0))
+
+
+def sustainable_verdict_narrative(sust_score: float, alpha: float) -> str:
+ """
+ Single source of truth for the SUSTAINABLE-dimension narrative verdict.
+
+ Reframes the old binary "Viable / Non-Viable" language into a
+ position-on-a-gradient + direction-of-travel against the *indicative*
+ ecological reference band. The numeric SUSTAINABLE score is unchanged; only
+ the wording changes. Never renders a bare absolute "Non-Viable" /
+ "UNSUSTAINABLE" pass/fail organizational judgment.
+ """
+ sust_score = float(sust_score)
+ grad = assess_alpha_position(alpha)
+ position = grad['position']
+ direction = grad['direction_of_travel']
+
+ if sust_score >= 75:
+ band_phrase = (
+ "sits within the indicative reference band"
+ if position == 'balanced'
+ else f"sits {position} relative to the indicative reference band"
+ )
+ return (
+ f"Strong sustainability balance (score: {sust_score:.0f}/100). "
+ f"On the efficiency/resilience gradient the organization {band_phrase} "
+ f"(alpha={alpha:.3f}); direction of travel: {direction}. "
+ f"{INDICATIVE_REFERENCE_CAVEAT}"
+ )
+ elif sust_score >= 50:
+ return (
+ f"Moderate sustainability (score: {sust_score:.0f}/100). "
+ f"On the efficiency/resilience gradient the organization reads as "
+ f"{position} relative to the indicative reference band "
+ f"(alpha={alpha:.3f}); direction of travel: {direction}. "
+ f"{INDICATIVE_REFERENCE_CAVEAT}"
+ )
+ else:
+ return (
+ f"Sustainability warrants attention (score: {sust_score:.0f}/100). "
+ f"On the efficiency/resilience gradient the organization reads as "
+ f"{position} relative to the indicative reference band "
+ f"(alpha={alpha:.3f}); direction of travel: {direction}. "
+ f"{INDICATIVE_REFERENCE_CAVEAT}"
+ )
+
+
+def executive_verdict(profile: Dict[str, Any]) -> str:
+ """One-sentence plain-language overall verdict for the executive layer."""
+ score = float(profile.get('overall_score', 0.0))
+ status = str(profile.get('overall_status', 'UNKNOWN'))
+ scores = profile.get('dimension_scores', {})
+ if scores:
+ weakest = min(scores, key=scores.get)
+ tail = f" The weakest dimension is {weakest.upper()} ({scores[weakest]:.0f}/100)."
+ else:
+ tail = ""
+ return (f"Overall organizational health is {score:.0f}/100 ({status})."
+ f"{tail}")
+
+
+def build_benchmark_view(metrics: Dict[str, Any],
+ profile: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Position the organization against the Window of Viability and published
+ ecological reference points. No new computation โ reads existing alpha/robustness
+ and looks up published relative_ascendency values.
+ """
+ alpha = _alpha(profile, metrics)
+ robustness = float(metrics.get('robustness',
+ profile.get('dimension_details', {}).get('sustainable', {})
+ .get('metrics', {}).get('robustness', 0.0)))
+
+ if alpha < VIABILITY_LOWER:
+ position = 'below'
+ elif alpha > VIABILITY_UPPER:
+ position = 'above'
+ else:
+ position = 'within'
+
+ anchors = _reference_anchors()
+
+ return {
+ 'alpha': alpha,
+ 'robustness': robustness,
+ 'lower': VIABILITY_LOWER,
+ 'upper': VIABILITY_UPPER,
+ 'optimum': ROBUSTNESS_OPTIMUM,
+ 'in_window': VIABILITY_LOWER <= alpha <= VIABILITY_UPPER,
+ 'position': position,
+ 'distance_to_optimum': abs(alpha - ROBUSTNESS_OPTIMUM),
+ 'reference_anchors': anchors,
+ }
+
+
+def _reference_anchors() -> List[Dict[str, Any]]:
+ """Published ecological reference points (labelled, NOT organizational targets)."""
+ try:
+ try:
+ from src.services import published_metrics_db as pdb
+ except Exception:
+ from services import published_metrics_db as pdb # 'src' on sys.path
+ except Exception:
+ return []
+ anchors = []
+ for net_id in pdb.list_networks():
+ ra = pdb.get_published_metric(net_id, 'relative_ascendency')
+ if ra is None:
+ continue
+ info = pdb.get_network_info(net_id) or {}
+ anchors.append({
+ 'id': net_id,
+ 'label': net_id.replace('_', ' ').title(),
+ 'relative_ascendency': float(ra),
+ 'source': info.get('source', ''),
+ 'note': 'Scientific reference point, not an organizational target.',
+ })
+ return anchors
+
+
+def build_risk_view(metrics: Dict[str, Any],
+ profile: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Fragility/resilience narrative built from existing alpha, overhead, redundancy,
+ and per-dimension status. No new computation.
+ """
+ alpha = _alpha(profile, metrics)
+ overhead_ratio = float(metrics.get('overhead_ratio', 0.0))
+ redundancy = float(metrics.get('redundancy', 0.0))
+
+ if alpha < VIABILITY_LOWER:
+ fragility = 'under-organized'
+ elif alpha > VIABILITY_UPPER:
+ fragility = 'over-organized'
+ else:
+ fragility = 'balanced'
+
+ items: List[Dict[str, Any]] = []
+
+ if fragility == 'over-organized':
+ items.append({
+ 'severity': 'HIGH',
+ 'title': 'System is rigid / brittle (over-organized)',
+ 'evidence': f'Relative ascendency alpha = {alpha:.3f} exceeds the upper '
+ f'viability bound ({VIABILITY_UPPER}).',
+ 'implication': 'Low adaptive reserve; efficient but vulnerable to shocks '
+ 'and unexpected change.',
+ })
+ elif fragility == 'under-organized':
+ items.append({
+ 'severity': 'HIGH',
+ 'title': 'System is chaotic (under-organized)',
+ 'evidence': f'Relative ascendency alpha = {alpha:.3f} is below the lower '
+ f'viability bound ({VIABILITY_LOWER}).',
+ 'implication': 'Abundant redundancy but weak coordination; activity may '
+ 'not translate into reliable outcomes.',
+ })
+ else:
+ items.append({
+ 'severity': 'LOW',
+ 'title': 'Balanced position within the indicative reference band',
+ 'evidence': f'Relative ascendency alpha = {alpha:.3f} lies within the '
+ f'indicative reference band [{VIABILITY_LOWER}, {VIABILITY_UPPER}]. '
+ f'{INDICATIVE_REFERENCE_CAVEAT}',
+ 'implication': 'Healthy balance of efficiency and resilience; direction of '
+ 'travel: maintain balance.',
+ })
+
+ # Distance-from-bound early warnings (within window but near an edge)
+ if fragility == 'balanced':
+ if (alpha - VIABILITY_LOWER) < 0.05:
+ items.append({'severity': 'MEDIUM',
+ 'title': 'Approaching lower viability bound',
+ 'evidence': f'alpha = {alpha:.3f} is within 0.05 of {VIABILITY_LOWER}.',
+ 'implication': 'Trend toward disorganization warrants monitoring.'})
+ if (VIABILITY_UPPER - alpha) < 0.05:
+ items.append({'severity': 'MEDIUM',
+ 'title': 'Approaching upper viability bound',
+ 'evidence': f'alpha = {alpha:.3f} is within 0.05 of {VIABILITY_UPPER}.',
+ 'implication': 'Trend toward rigidity warrants monitoring.'})
+
+ # Per-dimension status escalation
+ for dim, status in profile.get('dimension_status', {}).items():
+ if status in ('CRITICAL', 'WARNING'):
+ items.append({
+ 'severity': status,
+ 'title': f'{dim.upper()} dimension flagged {status}',
+ 'evidence': f'OASIS {dim} status = {status}.',
+ 'implication': 'Targeted intervention recommended โ see Action Roadmap.',
+ })
+
+ sev_order = {'CRITICAL': 0, 'HIGH': 1, 'WARNING': 2, 'MEDIUM': 3, 'LOW': 4}
+ items.sort(key=lambda it: sev_order.get(it['severity'], 5))
+
+ return {
+ 'fragility': fragility,
+ 'overhead_ratio': overhead_ratio,
+ 'redundancy': redundancy,
+ 'items': items,
+ }
+
+
+# Qualitative expected-impact phrasing per dimension (lookup, NOT a scoring model)
+_IMPACT_BY_DIMENSION = {
+ 'OPEN': 'Improves interconnectivity and information circulation across units.',
+ 'AUTONOMOUS': 'Strengthens feedback loops and institutional learning.',
+ 'SYMBIOTIC': 'Rebalances resource distribution and cooperation.',
+ 'INTELLIGENT': 'Increases functional diversity and specialization.',
+ 'SUSTAINABLE': 'Moves the system toward the Window of Viability (efficiency/'
+ 'resilience balance).',
+}
+
+
+def build_action_roadmap(recommendations: List[Dict[str, Any]],
+ profile: Dict[str, Any]) -> Dict[str, Any]:
+ """Sequence existing recommendations into Immediate/Short/Medium-term horizons."""
+ horizons = {'immediate': [], 'short_term': [], 'medium_term': []}
+ bucket = {'CRITICAL': 'immediate', 'HIGH': 'short_term',
+ 'MEDIUM': 'medium_term', 'LOW': 'medium_term'}
+ for rec in recommendations or []:
+ prio = rec.get('priority', 'MEDIUM')
+ dim = rec.get('dimension', 'N/A')
+ item = {
+ 'priority': prio,
+ 'dimension': dim,
+ 'issue': rec.get('issue', ''),
+ 'action': rec.get('action', ''),
+ 'metrics_to_improve': rec.get('metrics_to_improve', []),
+ 'expected_impact': _IMPACT_BY_DIMENSION.get(dim, 'Improves overall health.'),
+ }
+ horizons[bucket.get(prio, 'medium_term')].append(item)
+ return horizons
+
+
+# ---------------------------------------------------------------------------
+# ESG FRAMEWORK CROSSWALK โ indicative structural-lens mapping (NOT compliance)
+# ---------------------------------------------------------------------------
+# This is a FINDING-SPECIFIC crosswalk: it reads OASIS *structure* (how the org
+# is wired) and points to the disclosure areas that structural evidence informs.
+# It is NOT a compliance mapping and does NOT attest to any GRI/ESRS/TCFD
+# requirement. Where a framework code is a genuine analogue rather than a direct
+# disclosure (notably TCFD, a climate-financial framework, against non-climate
+# structural findings) it is flagged `contextual` and carries an explicit caveat
+# โ never presented as a direct disclosure. The dimension->construct mapping
+# follows docs/business-revision/evidence/expert-org-management.md ยง3.2.
+INDICATIVE_ESG_CAVEAT = (
+ "Indicative structural-lens crosswalk โ not a compliance attestation. It maps "
+ "OASIS network-structure findings to the disclosure areas they inform; it does "
+ "not verify, satisfy, or attest to any GRI, ESRS/CSRD, or TCFD requirement."
+)
+
+# Real framework structure used below (series/pillar granularity, no invented codes):
+# GRI 2 (General Disclosures 2021): 2-13 delegation, 2-16 critical concerns,
+# 2-17 collective knowledge of the highest governance body, 2-29 stakeholder
+# engagement. GRI 3 (Material Topics 2021): 3-3 management of material topics.
+# GRI 401 Employment; GRI 404 Training & education.
+# ESRS 2 General Disclosures: GOV-1 role/expertise of admin bodies, GOV-2 information
+# to bodies, SBM-2 stakeholder interests/views, SBM-3 material IROs & business-
+# model resilience, IRO-1 process to identify/assess IROs. ESRS S1 Own workforce;
+# ESRS G1 Business conduct.
+# TCFD pillars: Governance, Strategy, Risk Management, Metrics & Targets (climate-scoped).
+_ESG_CROSSWALK = {
+ 'OPEN': {
+ 'construct': 'boundary-spanning / information circulation / stakeholder connectivity',
+ 'theme': 'interconnectivity and information circulation',
+ 'frameworks': [
+ {'standard': 'GRI', 'code': 'GRI 2-29, 2-16',
+ 'label': 'Approach to stakeholder engagement; communication of critical concerns'},
+ {'standard': 'ESRS', 'code': 'ESRS 2 SBM-2, GOV-2',
+ 'label': 'Interests/views of stakeholders; information flow to administrative bodies'},
+ {'standard': 'TCFD', 'code': 'Governance',
+ 'label': 'Board oversight โ the channels by which risk/opportunity information reaches oversight',
+ 'contextual': True,
+ 'caveat': 'TCFD is climate-scoped; used here as a structural analogue for '
+ 'information-flow-to-oversight, not a climate disclosure.'},
+ ],
+ 'disclosure_relevance': (
+ 'Open (boundary-spanning and information circulation) evidences whether the '
+ 'stakeholder-engagement and information-flow processes disclosed under GRI 2-29 '
+ 'and ESRS 2 SBM-2/GOV-2 actually carry information across the organization and up '
+ 'to its oversight bodies โ the structural substrate beneath those qualitative claims.'),
+ },
+ 'AUTONOMOUS': {
+ 'construct': 'distributed decision rights / empowerment / feedback loops',
+ 'theme': 'organizational learning and devolved decision-making',
+ 'frameworks': [
+ {'standard': 'GRI', 'code': 'GRI 2-13, 3-3',
+ 'label': 'Delegation of responsibility for managing impacts; management of material topics'},
+ {'standard': 'ESRS', 'code': 'ESRS 2 GOV-1, IRO-1',
+ 'label': 'Role of administrative bodies; process to identify/assess/manage impacts, risks & opportunities'},
+ {'standard': 'TCFD', 'code': 'Risk Management',
+ 'label': 'Processes to identify, assess and manage risks โ whether detection/response is embedded and devolved',
+ 'contextual': True,
+ 'caveat': 'TCFD Risk Management is climate-scoped; the structural reading of '
+ 'devolved risk-detection is an analogue, not a climate disclosure.'},
+ ],
+ 'disclosure_relevance': (
+ 'Autonomous (distributed decision rights and feedback loops) informs how '
+ 'responsibility for managing impacts is delegated (GRI 2-13, ESRS 2 GOV-1) and '
+ 'whether risk identification and response are embedded across the organization '
+ 'rather than centralized (ESRS IRO-1; TCFD Risk Management as an analogue).'),
+ },
+ 'SYMBIOTIC': {
+ 'construct': 'cross-functional collaboration / relational coordination / reciprocity',
+ 'theme': 'cross-functional reciprocity and relational coordination',
+ 'frameworks': [
+ {'standard': 'GRI', 'code': 'GRI 3-3, 401',
+ 'label': 'Management of material social topics; employment / relational conditions'},
+ {'standard': 'ESRS', 'code': 'ESRS S1; G1',
+ 'label': 'Own workforce (social dialogue, working conditions); corporate culture / business conduct'},
+ {'standard': 'TCFD', 'code': 'Governance (contextual)',
+ 'label': 'Cross-functional collaboration is not a direct TCFD disclosure',
+ 'contextual': True,
+ 'caveat': 'TCFD is a climate-financial framework; cross-functional collaboration '
+ 'is shown only as contextual organizational-resilience input, not a TCFD disclosure.'},
+ ],
+ 'disclosure_relevance': (
+ 'Symbiotic (cross-functional reciprocity and relational coordination) evidences '
+ 'the collaboration and relational conditions in the own workforce that underlie '
+ 'ESRS S1 social disclosures and the corporate-culture element of ESRS G1 / GRI 3-3 '
+ 'โ the structural reciprocity beneath those qualitative social claims.'),
+ },
+ 'INTELLIGENT': {
+ 'construct': 'information-processing / learning / knowledge & functional diversity',
+ 'theme': 'functional diversity and information-processing capacity',
+ 'frameworks': [
+ {'standard': 'GRI', 'code': 'GRI 2-17, 404',
+ 'label': 'Collective knowledge of the highest governance body; training & education'},
+ {'standard': 'ESRS', 'code': 'ESRS 2 GOV-1; S1',
+ 'label': 'Expertise/skills of administrative bodies; skills development in own workforce'},
+ {'standard': 'TCFD', 'code': 'Governance',
+ 'label': 'Board competencies to assess and oversee risk',
+ 'contextual': True,
+ 'caveat': 'TCFD scopes board competency to climate risk; used here as an analogue '
+ 'for information-processing capacity, not a climate-competency disclosure.'},
+ ],
+ 'disclosure_relevance': (
+ 'Intelligent (functional diversity and information-processing capacity) informs the '
+ 'collective-knowledge and expertise conditions disclosed under GRI 2-17 and ESRS 2 '
+ 'GOV-1 โ the structural diversity that determines whether governance and workforce '
+ 'bodies can actually process the matters they are disclosed as overseeing.'),
+ },
+ 'SUSTAINABLE': {
+ 'construct': 'structural balance / efficiency-vs-resilience / adaptive capacity',
+ 'theme': 'efficiency/resilience structural balance (Window of Viability)',
+ 'frameworks': [
+ {'standard': 'GRI', 'code': 'GRI 3-3',
+ 'label': 'Management of the material topic of long-term organizational resilience',
+ 'contextual': True,
+ 'caveat': 'GRI has no dedicated structural-resilience disclosure; GRI 201-2 '
+ '(financial implications of climate change) is deliberately NOT used โ '
+ 'OASIS structural balance is not a climate-financial metric.'},
+ {'standard': 'ESRS', 'code': 'ESRS 2 SBM-3',
+ 'label': 'Material impacts, risks & opportunities and the resilience of the business model'},
+ {'standard': 'TCFD', 'code': 'Strategy โ Resilience',
+ 'label': 'Resilience of the strategy',
+ 'contextual': True,
+ 'caveat': 'TCFD frames strategic resilience under climate scenarios; OASIS measures '
+ 'structural (network) resilience โ a contextual analogue, not a '
+ 'climate-scenario disclosure.'},
+ ],
+ 'disclosure_relevance': (
+ 'Sustainable (efficiency/resilience structural balance โ the Window of Viability) '
+ 'provides a network-structural indicator of adaptive capacity that informs the '
+ 'business-model-resilience narrative of ESRS 2 SBM-3. It is explicitly distinct '
+ 'from the climate-financial risk addressed by GRI 201-2 / TCFD climate-scenario '
+ 'analysis, which this structural framework does not measure.'),
+ },
+}
+
+
+def _esg_materiality(status: str) -> Dict[str, Any]:
+ """
+ Status-driven materiality flag: reflects THIS org's finding, not a static table.
+
+ CRITICAL -> flagged for attention (potentially material disclosure area);
+ WARNING -> watch (emerging materiality signal);
+ HEALTHY -> supporting evidence (structural conditions favorable);
+ otherwise -> not assessed. Reads the precomputed OASIS dimension status; it
+ recomputes nothing.
+ """
+ s = (status or 'N/A').upper()
+ if s == 'CRITICAL':
+ return {'flag': 'attention', 'material': True,
+ 'label': 'Flagged for attention โ potentially material disclosure area'}
+ if s == 'WARNING':
+ return {'flag': 'watch', 'material': True,
+ 'label': 'Watch โ emerging materiality signal in this disclosure area'}
+ if s == 'HEALTHY':
+ return {'flag': 'supporting', 'material': False,
+ 'label': 'Supporting evidence โ structural conditions favorable for this disclosure area'}
+ return {'flag': 'not_assessed', 'material': False,
+ 'label': 'Not assessed'}
+
+
+def _esg_ref_string(frameworks: List[Dict[str, Any]], standard: str) -> str:
+ """Backward-compatible per-standard reference string (with contextual marker)."""
+ parts = []
+ for fw in frameworks:
+ if fw['standard'] != standard:
+ continue
+ code = fw['code']
+ if fw.get('contextual') and 'contextual' not in code.lower():
+ code = f"{code} (contextual)"
+ parts.append(code)
+ return '; '.join(parts) if parts else 'N/A'
+
+
+def build_esg_crosswalk(profile: Dict[str, Any],
+ metrics: Dict[str, Any]) -> List[Dict[str, Any]]:
+ """
+ Finding-specific, status-driven crosswalk from OASIS structural findings to
+ GRI / ESRS-CSRD / TCFD disclosure areas.
+
+ For each of the five dimensions it returns: the relevant framework mappings
+ (with contextual caveats where a code is an analogue rather than a direct
+ disclosure), a disclosure-relevance sentence describing what the structural
+ finding informs, and a materiality flag driven by the org's ACTUAL dimension
+ status from the precomputed OASIS profile. This is an INDICATIVE structural
+ lens only โ NOT a compliance attestation (see INDICATIVE_ESG_CAVEAT). No
+ scores or statuses are recomputed here.
+ """
+ scores = profile.get('dimension_scores', {})
+ status = profile.get('dimension_status', {})
+ rows = []
+ for dim in ['OPEN', 'AUTONOMOUS', 'SYMBIOTIC', 'INTELLIGENT', 'SUSTAINABLE']:
+ cw = _ESG_CROSSWALK[dim]
+ key = dim.lower()
+ sc = scores.get(key)
+ stt = status.get(key, 'N/A')
+ finding = (f"{dim.title()} ({cw['theme']}): "
+ + (f"score {sc:.0f}/100, status {stt}." if sc is not None
+ else "not assessed."))
+ frameworks = cw['frameworks']
+ rows.append({
+ 'oasis_dimension': dim,
+ 'construct': cw['construct'],
+ 'finding_summary': finding,
+ 'frameworks': frameworks,
+ 'disclosure_relevance': cw['disclosure_relevance'],
+ 'materiality': _esg_materiality(stt),
+ # backward-compatible per-standard strings for existing consumers:
+ 'gri_ref': _esg_ref_string(frameworks, 'GRI'),
+ 'esrs_ref': _esg_ref_string(frameworks, 'ESRS'),
+ 'tcfd_ref': _esg_ref_string(frameworks, 'TCFD'),
+ 'caveat': INDICATIVE_ESG_CAVEAT,
+ })
+ return rows
+
+
+def render_window_of_viability_png(alpha: float, robustness: float) -> bytes:
+ """
+ Render the robustness curve R(alpha) = -alpha*ln(alpha) with the organization's
+ (alpha, robustness) point and the viability band shaded. Returns PNG bytes.
+
+ The curve R = -alpha*ln(alpha) is the existing engine robustness definition
+ (Ulanowicz et al. 2009); it is plotted, not re-derived. Light theme for print.
+ """
+ import io
+ import numpy as np
+ import matplotlib
+ matplotlib.use('Agg')
+ import matplotlib.pyplot as plt
+
+ xs = np.linspace(0.001, 0.999, 400)
+ ys = -xs * np.log(xs)
+
+ fig, ax = plt.subplots(figsize=(7.2, 4.0), dpi=150)
+ ax.plot(xs, ys, color='#1a5f35', linewidth=2, label='Robustness R(ฮฑ) = โฮฑยทln(ฮฑ)')
+ ax.axvspan(VIABILITY_LOWER, VIABILITY_UPPER, color='#48c9b0', alpha=0.15,
+ label=f'Indicative reference band [{VIABILITY_LOWER}, {VIABILITY_UPPER}]')
+ ax.axvline(ROBUSTNESS_OPTIMUM, color='#d4a843', linestyle='--', linewidth=1,
+ label=f'Optimum ฮฑ โ {ROBUSTNESS_OPTIMUM:.2f}')
+
+ a = max(0.0, min(1.0, float(alpha)))
+ r = float(robustness) if robustness else (-a * np.log(a) if 0 < a < 1 else 0.0)
+ ax.scatter([a], [r], color='#c0392b', s=90, zorder=5,
+ label=f'This organization (ฮฑ={a:.3f})')
+
+ ax.set_xlabel('Relative Ascendency (ฮฑ = A/C)')
+ ax.set_ylabel('Robustness (R)')
+ ax.set_title('Position Relative to the Indicative Reference Band')
+ ax.set_facecolor('white')
+ fig.patch.set_facecolor('white')
+ ax.legend(fontsize=7, loc='upper right')
+ ax.grid(True, alpha=0.2)
+ fig.tight_layout()
+
+ buf = io.BytesIO()
+ fig.savefig(buf, format='png', facecolor='white')
+ plt.close(fig)
+ return buf.getvalue()
diff --git a/src/services/published_metrics_db.py b/src/services/published_metrics_db.py
index 2e54f5e..ffad792 100644
--- a/src/services/published_metrics_db.py
+++ b/src/services/published_metrics_db.py
@@ -24,6 +24,60 @@ class LogBase(Enum):
LOG10 = "log10" # log base 10
+# =============================================================================
+# BASE-DEPENDENCE OF METRICS
+# -----------------------------------------------------------------------------
+# Information-theoretic MAGNITUDES scale linearly with the logarithm base: a
+# value computed in nats (natural log, as the UlanowiczCalculator does) is
+# ``1/ln(2)`` times SMALLER than the same quantity in bits (log2). When a stored
+# published value is quoted in a different base than the engine computes, these
+# metrics MUST be base-converted before comparison (see
+# ``scientific_validation_agent.nats_to_bits``).
+#
+# Ratios / dimensionless indices are base-INVARIANT: the log base cancels in a
+# quotient (e.g. relative ascendency alpha = A/C) or the quantity never involves
+# a logarithm at all (e.g. total system throughput = a sum of flows). These must
+# NEVER be converted, or a correct value would be corrupted.
+# =============================================================================
+
+# Metrics whose magnitude changes with the log base (nats vs bits vs digits).
+BASE_DEPENDENT_METRICS = frozenset({
+ "ascendency",
+ "development_capacity",
+ "reserve",
+ "overhead",
+ "average_mutual_information",
+ "statistical_entropy",
+ "flow_diversity",
+ "conditional_entropy",
+ "structural_information",
+})
+
+# Metrics that are invariant to the log base (ratios, indices, raw flow sums).
+BASE_INVARIANT_METRICS = frozenset({
+ "relative_ascendency",
+ "ascendency_ratio",
+ "robustness",
+ "total_system_throughput",
+ "network_efficiency",
+ "finn_cycling_index",
+ "is_viable",
+ "regenerative_capacity",
+ "redundancy",
+})
+
+
+def is_base_dependent(metric_name: str) -> bool:
+ """Return True if a metric's magnitude scales with the logarithm base.
+
+ Base-dependent metrics require a nats<->bits conversion before a
+ cross-base published-value comparison; base-invariant ones must not be
+ converted. Unknown metric names default to base-invariant (no conversion)
+ so a comparison is never silently corrupted by an unexpected key.
+ """
+ return metric_name in BASE_DEPENDENT_METRICS
+
+
@dataclass
class PublishedMetric:
"""A single published metric with its value and metadata."""
@@ -44,6 +98,12 @@ class NetworkPublishedData:
tolerance: float = 0.05 # 5% default tolerance
metrics: Dict[str, PublishedMetric] = field(default_factory=dict)
notes: List[str] = field(default_factory=list)
+ # When True, the entry is a published-literature reference value (e.g. a
+ # benchmark anchor quoted directly from a paper's prose) that is NOT tied to
+ # a recomputable flow matrix in NETWORK_DATA_FILES. The computational
+ # validation agent skips these instead of erroring on a missing data file;
+ # they are still exposed as scientific reference anchors in the report.
+ reference_only: bool = False
# =============================================================================
@@ -174,24 +234,72 @@ class NetworkPublishedData:
),
# =========================================================================
- # FLORIDA BAY
+ # SOUTH FLORIDA EVERGLADES - Heymans et al. (2002) reference anchors
+ # -------------------------------------------------------------------------
+ # DATA-PROVENANCE FIX (replaces the former mislabeled "florida_bay" entry):
+ # The prior entry stored relative ascendency alpha = 0.367 citing
+ # "Heymans et al. 2002" with a "subtropical seagrass / shallow marine"
+ # description. That is unsourceable: Heymans, Ulanowicz & Bondavalli (2002),
+ # "Network analysis of the South Florida Everglades graminoid marshes and
+ # comparison with nearby cypress ecosystems", Ecological Modelling 149:5-23,
+ # is about a FRESHWATER graminoid marsh and a cypress swamp, NOT a marine
+ # seagrass bay, and it never reports 0.367 (which happens to equal 1/e used
+ # elsewhere in the code as the robustness optimum).
+ #
+ # The paper reports relative ascendency directly, as whole-percent prose on
+ # p.20 (Section 3.3, "System-level analysis"):
+ # "... the relative ascendency of 52% for the graminoids is higher than
+ # any such index they had encountered ... The relative ascendency of 34%
+ # reported for the cypress is lower than most of the relative
+ # ascendencies calculated by NETWRK ..."
+ # (%AC, ascendency as a percentage of development capacity, IS the relative
+ # ascendency alpha = A/C.) These two values are stored below.
+ #
+ # They are reference_only anchors: the paper gives alpha as a percentage in
+ # prose without a published A/C/Phi breakdown, and no flow matrix shipped in
+ # this repo reproduces the paper's alpha, so they are quoted literature
+ # values (benchmark anchors), not recomputable-from-JSON entries.
# =========================================================================
- "florida_bay": NetworkPublishedData(
+ "everglades_graminoid": NetworkPublishedData(
+ source="Heymans et al. 2002",
+ doi="10.1016/S0304-3800(01)00511-7",
+ page=20, # Section 3.3 "System-level analysis": "relative ascendency of 52%"
+ log_base=LogBase.NATURAL,
+ tolerance=0.10,
+ reference_only=True,
+ metrics={
+ "relative_ascendency": PublishedMetric(
+ value=0.52, # 52% per Heymans et al. 2002, p.20 (prose)
+ unit="dimensionless",
+ note="alpha = A/C = 52% (Heymans et al. 2002, p.20)"
+ ),
+ },
+ notes=[
+ "South Florida Everglades freshwater graminoid marsh (sawgrass)",
+ "Two-dimensional wetland dominated by periphyton primary production",
+ "Exceptionally high relative ascendency (52%) -> tightly organized, "
+ "efficient but relatively fragile system",
+ ]
+ ),
+ "everglades_cypress": NetworkPublishedData(
source="Heymans et al. 2002",
- doi=None,
+ doi="10.1016/S0304-3800(01)00511-7",
+ page=20, # Section 3.3 "System-level analysis": "relative ascendency of 34%"
log_base=LogBase.NATURAL,
tolerance=0.10,
+ reference_only=True,
metrics={
"relative_ascendency": PublishedMetric(
- value=0.367,
+ value=0.34, # 34% per Heymans et al. 2002, p.20 (prose)
unit="dimensionless",
- note="Lower organization, higher resilience"
+ note="alpha = A/C = 34% (Heymans et al. 2002, p.20)"
),
},
notes=[
- "Subtropical seagrass-dominated ecosystem",
- "Shallow marine environment",
- "Value indicates good balance between efficiency and resilience"
+ "Big Cypress Preserve / Fakahatchee Strand cypress swamp",
+ "Three-dimensional forested wetland with higher primary-producer diversity",
+ "Lower relative ascendency (34%) -> more overhead/redundancy, "
+ "greater long-term resilience",
]
),
@@ -426,6 +534,12 @@ def get_network_info(network_id: str) -> Optional[Dict[str, Any]]:
"cone_spring_original": "data/ecosystem_samples/cone_spring_original.json",
"cone_spring_eutrophicated": "data/ecosystem_samples/cone_spring_eutrophicated.json",
"crystal_river_creek": "data/ecosystem_samples/crystal_river_creek.json",
+ # florida_bay.json is a genuine Ulanowicz et al. (1998) Florida Bay marine
+ # food web (see the file's own metadata) and is still used by
+ # validation/test_florida_bay.py. It is intentionally NOT tied to a
+ # PUBLISHED_METRICS entry: the former "florida_bay" metrics entry that cited
+ # Heymans 2002 was mislabeled and has been replaced by the everglades_*
+ # reference anchors above.
"florida_bay": "data/ecosystem_samples/florida_bay.json",
"prawns_alligator_original": "data/ecosystem_samples/prawns_alligator_original.json",
"prawns_alligator_efficient": "data/ecosystem_samples/prawns_alligator_efficient.json",
diff --git a/src/services/scientific_validation_agent.py b/src/services/scientific_validation_agent.py
index 86012b4..e1af9b3 100644
--- a/src/services/scientific_validation_agent.py
+++ b/src/services/scientific_validation_agent.py
@@ -43,9 +43,33 @@
list_networks,
list_metrics,
get_network_info,
+ is_base_dependent,
)
+# ln(2), precomputed once. Used to reconcile the engine's natural-log (nats)
+# information magnitudes against published values quoted in bits (log base 2).
+_LN2 = math.log(2)
+
+
+def nats_to_bits(value_nats: float) -> float:
+ """Convert an information-theoretic magnitude from nats to bits.
+
+ The engine (:class:`UlanowiczCalculator`) computes Ascendency, Development
+ Capacity, Overhead, AMI and flow diversity with the natural logarithm, so
+ those magnitudes are in **nats**. Papers such as Ulanowicz & Norden (1990)
+ report them in **bits** (log base 2). Since ``log2(x) = ln(x) / ln(2)`` and
+ ``ln(2) < 1``, dividing a nats value by ``ln(2)`` (equivalently multiplying
+ by ``log2(e)``) *increases* its magnitude -- the correct nats->bits
+ direction.
+
+ This is applied ONLY to base-dependent magnitudes; ratios/indices such as
+ relative ascendency and robustness are base-invariant and must be left
+ untouched.
+ """
+ return value_nats / _LN2
+
+
class ValidationStatus(Enum):
"""Status of a validation check."""
PASS = "pass"
@@ -142,33 +166,43 @@ def _compute_metrics(self, flow_matrix: np.ndarray, log_base: LogBase) -> Dict[s
"""
Compute all metrics for a flow matrix.
+ The engine always computes information-theoretic magnitudes in nats
+ (natural log). Base reconciliation against published values is done
+ per-metric at comparison time via :meth:`_convert_engine_value`, so this
+ method returns the raw engine metrics unchanged regardless of
+ ``log_base``.
+
Args:
flow_matrix: The network flow matrix
- log_base: The logarithm base to use (for comparison with published values)
+ log_base: The logarithm base of the published values (unused here;
+ kept for signature stability / callers).
Returns:
- Dictionary of computed metrics
+ Dictionary of computed metrics (magnitudes in nats).
"""
calc = UlanowiczCalculator(flow_matrix)
+ return calc.get_extended_metrics()
- # Get extended metrics
- metrics = calc.get_extended_metrics()
-
- # If paper used log base 2, we need to convert our natural log results
- # Conversion: log2(x) = ln(x) / ln(2)
- if log_base == LogBase.LOG2:
- ln2 = math.log(2)
- # Scale information-theoretic metrics
- if 'development_capacity' in metrics:
- metrics['development_capacity_log2'] = metrics['development_capacity'] / ln2
- if 'ascendency' in metrics:
- metrics['ascendency_log2'] = metrics['ascendency'] / ln2
- if 'reserve' in metrics:
- metrics['reserve_log2'] = metrics['reserve'] / ln2
- if 'average_mutual_information' in metrics:
- metrics['ami_log2'] = metrics['average_mutual_information'] / ln2
-
- return metrics
+ def _convert_engine_value(
+ self,
+ metric_name: str,
+ value: float,
+ log_base: LogBase,
+ ) -> float:
+ """Reconcile an engine value (nats) to the published value's log base.
+
+ Base-dependent magnitudes are converted nats->bits ONLY when the
+ published value is in log base 2. Base-invariant metrics (ratios,
+ indices, raw flow sums) and any non-LOG2 / unknown base are returned
+ unchanged -- the engine already computes in nats, and force-applying a
+ conversion to a NATURAL-base network or a base-invariant ratio would
+ corrupt a correct value.
+ """
+ if value is None:
+ return value
+ if log_base == LogBase.LOG2 and is_base_dependent(metric_name):
+ return nats_to_bits(value)
+ return value
def _compare_metric(
self,
@@ -341,6 +375,27 @@ def validate_network(self, network_id: str) -> NetworkValidationResult:
summary=f"Network '{network_id}' not found in published metrics database"
)
+ # Reference-only anchors are published-literature values (e.g. a
+ # benchmark alpha quoted from a paper's prose) with no recomputable flow
+ # matrix. They are not validated computationally; skip cleanly instead
+ # of erroring on a missing data file.
+ pub_entry = PUBLISHED_METRICS.get(network_id)
+ if pub_entry is not None and getattr(pub_entry, "reference_only", False):
+ return NetworkValidationResult(
+ network_id=network_id,
+ network_name=network_info.get('source', network_id),
+ source=network_info['source'],
+ timestamp=timestamp,
+ computed_metrics={},
+ metric_comparisons=[],
+ validation_checks=[],
+ overall_status=ValidationStatus.SKIP,
+ summary=(
+ f"Network '{network_id}' is a published-literature reference "
+ f"anchor (reference_only); no recomputable flow matrix to validate."
+ )
+ )
+
# Load network data
network_data = self._load_network_data(network_id)
if network_data is None:
@@ -371,19 +426,22 @@ def validate_network(self, network_id: str) -> NetworkValidationResult:
metric_comparisons = []
published_metrics = network_info['metrics']
- # Map metric names for comparison
+ # Map a published metric name to the engine key that holds the same
+ # quantity, where they differ. The engine calls the flow-based Shannon
+ # entropy H = C/TST "flow_diversity"; the papers call it statistical
+ # entropy. Base reconciliation (nats->bits) is handled separately, per
+ # metric, by _convert_engine_value -- NOT by name-mangled keys.
metric_mapping = {
- 'total_system_throughput': 'total_system_throughput',
- 'development_capacity': 'development_capacity_log2' if log_base == LogBase.LOG2 else 'development_capacity',
- 'ascendency': 'ascendency_log2' if log_base == LogBase.LOG2 else 'ascendency',
- 'reserve': 'reserve_log2' if log_base == LogBase.LOG2 else 'reserve',
- 'relative_ascendency': 'relative_ascendency',
- 'average_mutual_information': 'ami_log2' if log_base == LogBase.LOG2 else 'average_mutual_information',
+ 'statistical_entropy': 'flow_diversity',
+ 'overhead': 'reserve',
}
for pub_name, pub_data in published_metrics.items():
computed_name = metric_mapping.get(pub_name, pub_name)
- computed_value = computed_metrics.get(computed_name, computed_metrics.get(pub_name, 0))
+ raw_computed = computed_metrics.get(computed_name, computed_metrics.get(pub_name, 0))
+ # Reconcile the engine's nats magnitude to the published value's log
+ # base. Base-invariant metrics and NATURAL/unknown bases pass through.
+ computed_value = self._convert_engine_value(pub_name, raw_computed, log_base)
published_value = pub_data['value'] if pub_data['reported'] else None
comparison = self._compare_metric(
diff --git a/src/ulanowicz_calculator.py b/src/ulanowicz_calculator.py
index fe3c021..f181c4a 100644
--- a/src/ulanowicz_calculator.py
+++ b/src/ulanowicz_calculator.py
@@ -443,15 +443,28 @@ def assess_sustainability(self) -> str:
ascendency = metrics['ascendency']
lower_bound = metrics['viability_lower_bound']
upper_bound = metrics['viability_upper_bound']
-
- if ascendency < lower_bound:
- return "UNSUSTAINABLE - Too chaotic (low organization)"
- elif ascendency > upper_bound:
- return "UNSUSTAINABLE - Too rigid (over-organized)"
+
+ # Reframed: gradient position + direction-of-travel relative to the
+ # INDICATIVE ecological reference band (single source of truth). Not a
+ # binary pass/fail viability verdict.
+ try:
+ from report_intelligence import assess_alpha_position
+ except ImportError: # pragma: no cover
+ from src.report_intelligence import assess_alpha_position
+ grad = assess_alpha_position(metrics.get('ascendency_ratio', 0))
+ pos = grad['position']
+ direction = grad['direction_of_travel']
+
+ if pos == 'under-organized':
+ return (f"Under-organized relative to the indicative reference band "
+ f"โ direction of travel: {direction}")
+ elif pos == 'over-organized':
+ return (f"Over-organized relative to the indicative reference band "
+ f"โ direction of travel: {direction}")
elif ascendency < (lower_bound + upper_bound) / 2:
- return "VIABLE - Leaning toward flexibility"
+ return "Balanced within the indicative reference band (leaning toward flexibility)"
else:
- return "VIABLE - Leaning toward organization"
+ return "Balanced within the indicative reference band (leaning toward organization)"
def calculate_flow_diversity(self) -> float:
"""
@@ -596,62 +609,84 @@ def calculate_effective_link_density(self) -> float:
return (active_links / max_links) * (ami / max_ami)
- def calculate_trophic_depth(self) -> float:
+ def calculate_effective_trophic_levels(self) -> np.ndarray:
"""
- Calculate average Trophic Depth (hierarchical levels) of the network.
+ Flow-weighted effective trophic level of each compartment (Levine 1980).
+
+ Levine (1980), as presented in Ulanowicz 2004 ยง4 (p.327), defines the
+ effective trophic level of a compartment as the corresponding COLUMN-SUM
+ of the Leontief structure matrix [S] built from the diet/inflow
+ proportions:
- Trophic depth measures the average number of steps/levels in the
- network hierarchy, similar to trophic levels in ecology.
- Uses unweighted path lengths to count actual hierarchical steps.
+ - G[:, j] = T[:, j] / T_j_in (column-normalized by inflow; each
+ column of G is the fractional diet composition of compartment j).
+ - S = (I - G)^-1.
+ - effective trophic level of j = ฮฃ_i S[i, j] (column-sum of S).
+
+ Because the levels are FLOW-WEIGHTED they can be fractional: a
+ compartment fed 60% from level 2, 30% from level 3 and 10% from level 4
+ has effective level 0.6ยท2 + 0.3ยท3 + 0.1ยท4 = 2.5 (Ulanowicz 2004 Fig. 4),
+ which an unweighted shortest-path hop count cannot reproduce.
Returns:
- Average Trophic Depth value (typically 1-10 for real networks)
+ 1-D array of effective trophic levels, one per compartment.
"""
- # Skip for networks > 50 nodes (computationally expensive)
- if self.n_nodes > 50:
- return 0.0
+ n = self.n_nodes
+ if n == 0:
+ return np.zeros(0)
- # Create networkx graph for path analysis (unweighted for level counting)
- G = nx.DiGraph()
+ t_in = self.input_throughput # internal inflow to each compartment (col sum)
- for i in range(self.n_nodes):
- G.add_node(i)
- for j in range(self.n_nodes):
- if self.flow_matrix[i, j] > 0:
- G.add_edge(i, j) # No weight - count hops, not flow magnitude
-
- if G.number_of_edges() == 0:
- return 0.0
+ G = np.zeros((n, n), dtype=np.float64)
+ for j in range(n):
+ if t_in[j] > 0:
+ G[:, j] = self.flow_matrix[:, j] / t_in[j]
- # Calculate average shortest path length (unweighted = number of levels)
+ identity = np.eye(n)
try:
- avg_path_length = nx.average_shortest_path_length(G)
- return avg_path_length
- except nx.NetworkXError:
- # Graph is not strongly connected
- # Calculate for weakly connected component or return estimate
+ S = np.linalg.inv(identity - G)
+ except np.linalg.LinAlgError:
+ eps = 1e-9
try:
- # Try largest strongly connected component
- largest_scc = max(nx.strongly_connected_components(G), key=len, default=set())
- if len(largest_scc) > 1:
- subgraph = G.subgraph(largest_scc)
- return nx.average_shortest_path_length(subgraph)
- except:
- pass
+ S = np.linalg.inv(identity - (1.0 - eps) * G)
+ except np.linalg.LinAlgError:
+ return np.ones(n)
- # Fallback: estimate from graph diameter or density
- try:
- # Use weakly connected component
- largest_wcc = max(nx.weakly_connected_components(G), key=len, default=set())
- if len(largest_wcc) > 1:
- subgraph = G.subgraph(largest_wcc).to_undirected()
- if nx.is_connected(subgraph):
- return nx.average_shortest_path_length(subgraph)
- except:
- pass
+ levels = np.sum(S, axis=0)
+ # Guard against numerical noise / non-finite entries in near-singular nets
+ levels = np.where(np.isfinite(levels), levels, 1.0)
+ return levels
+
+ def calculate_trophic_depth(self) -> float:
+ """
+ Trophic depth = maximum flow-weighted effective trophic level.
+
+ Trophic depth measures how many hierarchical levels the network spans.
+ It is the maximum of the flow-weighted effective trophic levels (Levine
+ 1980; Ulanowicz 2004 ยง4), NOT an unweighted shortest-path hop count.
+
+ NOTE (Track-1 correction): the previous implementation used
+ ``nx.average_shortest_path_length``, an UNWEIGHTED topological hop count
+ that ignores flow magnitudes and can never reproduce the fractional
+ effective levels the ENA literature requires (e.g. 2.5 in Ulanowicz 2004
+ Fig. 4). It is replaced by the flow-weighted Levine effective trophic
+ level (column-sums of the Leontief structure matrix).
+
+ Returns:
+ Trophic depth (max effective trophic level; >= 1 for a live network).
+ """
+ # Skip for networks > 50 nodes (matrix inverse; keep the guard cheap)
+ if self.n_nodes > 50:
+ return 0.0
+ if self._tst == 0:
return 0.0
+ levels = self.calculate_effective_trophic_levels()
+ if levels.size == 0:
+ return 0.0
+ return float(np.max(levels))
+
def calculate_conditional_entropy(self) -> float:
"""
Calculate Conditional Entropy (Hc).
@@ -694,21 +729,26 @@ def calculate_redundancy(self) -> float:
return overhead / development_capacity if development_capacity > 0 else 0
- def calculate_finn_cycling_index(self) -> float:
+ def calculate_short_cycle_proxy(self) -> float:
"""
- Calculate Finn Cycling Index (FCI).
+ Short-cycle cycling proxy (NOT the full Finn Cycling Index).
- FCI measures the fraction of total system throughput that is involved in cycling.
- This is a key indicator of system regeneration and resource efficiency.
+ This O(nยฒ) heuristic detects ONLY:
+ 1. Self-loops (diagonal elements)
+ 2. Two-node reciprocal cycles (A->B and B->A)
- Uses an O(nยฒ) algorithm that detects:
- 1. Self-loops (diagonal elements)
- 2. Two-node reciprocal cycles (A->B and B->A)
+ It therefore MISSES every cycle of length >= 3 and returns ~0 for a
+ pure directed ring whose medium actually recycles ~100%. It is a strict
+ lower bound on cycling, valid only when cycling is dominated by self- and
+ 2-cycles.
- Since metrics are precomputed and cached, no size threshold is needed.
+ For the standards-compliant Finn Cycling Index (Finn 1976; Ulanowicz
+ 2004 ยง5), use ``calculate_finn_cycling_index_full`` (internal-only) or
+ ``EcosystemFlowCalculator.calculate_finn_cycling_index`` (with boundary
+ flows), which build the column-normalized Leontief structure matrix.
Returns:
- Finn Cycling Index value between 0 and 1
+ Short-cycle cycling proxy in [0, 1]
"""
tst = self.calculate_tst()
@@ -725,9 +765,76 @@ def calculate_finn_cycling_index(self) -> float:
np.fill_diagonal(reciprocal, 0) # Don't double-count self-loops
cycling_flow += np.sum(reciprocal) / 2 # Divide by 2 to avoid double counting
- # FCI = cycling flow / total throughput
- fci = min(cycling_flow / tst, 1.0) if tst > 0 else 0
- return fci
+ # proxy = cycling flow / total throughput
+ proxy = min(cycling_flow / tst, 1.0) if tst > 0 else 0
+ return proxy
+
+ # Back-compat alias: the historical name pointed at the short-cycle proxy.
+ # Kept so existing consumers (app.py, precompute_service, oasis_calculator,
+ # reports) do not break. This is the PROXY, not the canonical Finn index.
+ def calculate_finn_cycling_index(self) -> float:
+ """Deprecated alias for :meth:`calculate_short_cycle_proxy`.
+
+ WARNING: despite the name, this is the short-cycle PROXY (self-loops +
+ 2-cycles only), not the canonical Finn Cycling Index. For the full,
+ standards-compliant Finn index use
+ :meth:`calculate_finn_cycling_index_full`.
+ """
+ return self.calculate_short_cycle_proxy()
+
+ def calculate_finn_cycling_index_full(self) -> float:
+ """
+ Canonical Finn Cycling Index (internal-only, no boundary flows).
+
+ Builds the column-normalized Leontief structure matrix and reads the
+ diagonal cycling probabilities (Finn 1976; Ulanowicz 2004 ยง5 p.330;
+ Fath 2019 Principle 2 p.20):
+
+ - G[:, j] = T[:, j] / T_j_in, where T_j_in is the internal inflow to j
+ (column sum of the internal flow matrix; imports are unavailable at
+ this level โ use ``EcosystemFlowCalculator.calculate_finn_cycling_index``
+ for boundary-inclusive networks).
+ - S = (I - G)^-1 (Leontief structure matrix).
+ - TSTc = ฮฃ_i ((S[i,i] - 1) / S[i,i]) ยท T_i.
+ - FCI = TSTc / TST.
+
+ A perfectly conservative internal structure (e.g. a pure ring) is the
+ singular limit of full recycling and yields FCI -> 1; a regularized
+ (vanishing-leak) inverse recovers that limit.
+
+ Returns:
+ Finn Cycling Index in [0, 1]
+ """
+ n = self.n_nodes
+ tst = self.calculate_tst()
+ if tst == 0:
+ return 0.0
+
+ t_in = self.input_throughput # internal inflow to each compartment (col sum)
+
+ G = np.zeros((n, n), dtype=np.float64)
+ for j in range(n):
+ if t_in[j] > 0:
+ G[:, j] = self.flow_matrix[:, j] / t_in[j]
+
+ identity = np.eye(n)
+ try:
+ S = np.linalg.inv(identity - G)
+ except np.linalg.LinAlgError:
+ eps = 1e-9
+ try:
+ S = np.linalg.inv(identity - (1.0 - eps) * G)
+ except np.linalg.LinAlgError:
+ return 0.0
+
+ diag = np.diag(S)
+ with np.errstate(divide='ignore', invalid='ignore'):
+ cycled_fraction = np.where(np.isfinite(diag) & (diag > 0),
+ (diag - 1.0) / diag, 1.0)
+ cycled_fraction = np.clip(cycled_fraction, 0.0, 1.0)
+
+ tst_c = float(np.sum(cycled_fraction * t_in))
+ return max(0.0, min(1.0, tst_c / tst))
def calculate_autocatalytic_index(self) -> Dict[str, Any]:
"""
@@ -815,7 +922,14 @@ def calculate_autocatalytic_index(self) -> Dict[str, Any]:
expected_cycles = self.n_nodes * (self.n_nodes - 1) / 2 # Rough expectation
count_factor = min(1, len(cycles) / max(1, expected_cycles))
- autocatalytic_index = 0.5 * count_factor + 0.5 * min(1, cycle_flow_ratio * 10)
+ # Flow component: use cycle_flow_ratio DIRECTLY (already a proportion in
+ # [0, 1]). The former `* 10` amplifier had no basis and saturated the
+ # component for any network with >10% cycled flow; removing it
+ # de-saturates the term. Clamp retained only as a numerical guard.
+ # (Kept in sync with OASISCalculator.calculate_autocatalytic_index.)
+ flow_component = min(1.0, cycle_flow_ratio)
+
+ autocatalytic_index = 0.5 * count_factor + 0.5 * flow_component
return {
'count': len(cycles),
@@ -955,8 +1069,16 @@ def calculate_network_topology_metrics(self) -> Dict[str, float]:
sum_diff_in = sum(max_in_degree - d for d in in_degrees.values())
sum_diff_out = sum(max_out_degree - d for d in out_degrees.values())
-
- max_possible_diff = (n - 1) * (n - 2)
+
+ # Freeman (1979) directed normalizer. For raw in/out degree of a
+ # DIRECTED graph the theoretical maximum of sum(d* - d_i) is realized
+ # by a perfect in/out-star (one node with degree n-1, the rest 0),
+ # giving (n-1)*(n-1) = (n-1)^2. The classic (n-1)(n-2) denominator is
+ # the UNDIRECTED star maximum and under-normalizes directed degrees
+ # (pushing the coefficient above 1). See
+ # docs/business-revision/evidence/validation-EF-network-stats.md (N4)
+ # and expert-mathematician.md (M6a).
+ max_possible_diff = (n - 1) ** 2
metrics['in_degree_centralization'] = sum_diff_in / max_possible_diff if max_possible_diff > 0 else 0
metrics['out_degree_centralization'] = sum_diff_out / max_possible_diff if max_possible_diff > 0 else 0
@@ -1046,13 +1168,24 @@ def calculate_effective_connectivity(self) -> float:
"""
Calculate effective connectivity (C).
- Based on Zorach & Ulanowicz (2003), effective connectivity is
- calculated directly from the flow distribution.
+ Based on Zorach & Ulanowicz (2003), effective connectivity is the
+ number of effective flows per effective node.
+
+ Formula: C = F / N (Zorach & Ulanowicz 2003, p.72: "C โก F/N").
+ This is the average number of flows per node and is bounded below by
+ 1.0 for a connected network (Ulanowicz 2004, p.334: the lower limit of
+ the window of vitality is 1.0). The identities R = F/Cยฒ = N/C follow.
- Formula: C = exp(0.5 * ฮฃ((Tij/Tโขโข) * log(Tijยฒ/(Tiโข*Tโขj))))
+ NOTE (Track-1 correction): the previous implementation used the literal
+ product-form C = exp(0.5ยทฮฃ wยทln(Tijยฒ/(TiยทTj))), which carries a POSITIVE
+ exponent. The canonical form (Z-U 2003 Appendix p.76) has a NEGATIVE
+ exponent; the positive form equals N/F (the reciprocal, always < 1) and
+ violates the C โฅ 1 connectivity floor. Computing C = F/N directly is the
+ cleanest form and guarantees the identity block holds to machine
+ precision.
Returns:
- Effective connectivity in flows per node
+ Effective connectivity in flows per node (>= 1.0 for a connected net)
"""
# Use vectorized version if enabled
if self.use_vectorized:
@@ -1065,25 +1198,11 @@ def calculate_effective_connectivity(self) -> float:
)
return self._vectorized_cache['effective_connectivity']
- # Original implementation - now using precomputed sums
- tst = self._tst
- if tst == 0:
+ # Original implementation: C = F / N (Zorach & Ulanowicz 2003, p.72)
+ eff_nodes = self.calculate_effective_nodes()
+ if eff_nodes <= 0:
return 0
-
- sum_term = 0
- for i in range(self.n_nodes):
- for j in range(self.n_nodes):
- if self.flow_matrix[i, j] > 0:
- tij = self.flow_matrix[i, j]
- # Use precomputed throughputs instead of recomputing
- ti_out = self.output_throughput[i]
- tj_in = self.input_throughput[j]
-
- if ti_out > 0 and tj_in > 0:
- weight = tij / tst
- sum_term += weight * np.log(tij**2 / (ti_out * tj_in))
-
- return np.exp(0.5 * sum_term)
+ return self.calculate_effective_flows() / eff_nodes
def calculate_number_of_roles(self) -> float:
"""
diff --git a/src/vectorized_metrics.py b/src/vectorized_metrics.py
index eb89102..543f51d 100644
--- a/src/vectorized_metrics.py
+++ b/src/vectorized_metrics.py
@@ -352,10 +352,20 @@ def vectorized_effective_connectivity(flow_matrix: np.ndarray,
"""
Calculate effective connectivity (C) using vectorized operations.
- Based on Zorach & Ulanowicz (2003), effective connectivity is
- calculated directly from the flow distribution.
+ Based on Zorach & Ulanowicz (2003), effective connectivity is the number
+ of effective flows per effective node.
- Formula: C = exp(0.5 * ฮฃ((T_ij/Tยทยท) * ln(T_ijยฒ / (T_iยท * T_ยทj))))
+ Formula: C = F / N (Zorach & Ulanowicz 2003, p.72: "C โก F/N").
+ This is the average number of flows per node, bounded below by 1.0 for a
+ connected network (Ulanowicz 2004, p.334). The identities R = F/Cยฒ = N/C
+ follow.
+
+ NOTE (Track-1 correction): the previous form C = exp(0.5ยทฮฃ wยทln(Tijยฒ/โฆ))
+ carries a positive exponent; the canonical form (Z-U 2003 Appendix p.76)
+ has a NEGATIVE exponent. The positive form equals N/F (the reciprocal,
+ always < 1) and violates the C โฅ 1 floor. Computing C = F/N directly keeps
+ this vectorized path in exact agreement with the loop implementation and
+ guarantees the identity block.
Args:
flow_matrix: Square matrix of flows between nodes
@@ -364,7 +374,7 @@ def vectorized_effective_connectivity(flow_matrix: np.ndarray,
tst: Precomputed total system throughput
Returns:
- Effective connectivity in flows per node
+ Effective connectivity in flows per node (>= 1.0 for a connected net)
"""
flow_matrix = np.asarray(flow_matrix, dtype=np.float64)
@@ -374,26 +384,12 @@ def vectorized_effective_connectivity(flow_matrix: np.ndarray,
if tst == 0:
return 0.0
- # Outer product: T_iยท * T_ยทj
- outer_product = np.outer(row_sums, col_sums)
-
- with np.errstate(divide='ignore', invalid='ignore'):
- valid_mask = (flow_matrix > 0) & (outer_product > 0)
-
- # Weight: T_ij / Tยทยท
- weights = flow_matrix / tst
-
- # Ratio: T_ijยฒ / (T_iยท * T_ยทj)
- ratios = np.zeros_like(flow_matrix)
- ratios[valid_mask] = (flow_matrix[valid_mask] ** 2) / outer_product[valid_mask]
-
- # Log terms
- log_ratios = np.where(ratios > 0, np.log(ratios), 0)
-
- # Weighted sum
- sum_term = np.sum(np.where(valid_mask, weights * log_ratios, 0))
+ eff_nodes = vectorized_effective_nodes(flow_matrix, row_sums, col_sums, tst)
+ if eff_nodes <= 0:
+ return 0.0
- return np.exp(0.5 * sum_term)
+ eff_flows = vectorized_effective_flows(flow_matrix, tst)
+ return eff_flows / eff_nodes
def vectorized_number_of_roles(flow_matrix: np.ndarray,
diff --git a/tests/connectors/__init__.py b/tests/connectors/__init__.py
new file mode 100644
index 0000000..fe1c1a5
--- /dev/null
+++ b/tests/connectors/__init__.py
@@ -0,0 +1 @@
+# Test package for src.connectors
diff --git a/tests/connectors/test_gmail_connector.py b/tests/connectors/test_gmail_connector.py
new file mode 100644
index 0000000..aa61a39
--- /dev/null
+++ b/tests/connectors/test_gmail_connector.py
@@ -0,0 +1,94 @@
+from src.connectors.gmail_connector import GmailConnector
+
+
+class FakeAdmin:
+ """Mimics the two calls the connector makes on the Admin SDK."""
+ def list_users(self):
+ return [
+ {"primaryEmail": "a@x.com", "orgUnitPath": "/Sales"},
+ {"primaryEmail": "b@x.com", "orgUnitPath": "/IT"},
+ ]
+
+
+class FakeGmail:
+ """Returns metadata headers for one sent message with To + Cc."""
+ def list_sent_messages(self, user_email, start_ts, end_ts):
+ if user_email != "a@x.com":
+ return []
+ return [{
+ "ts_utc": (start_ts + end_ts) // 2,
+ "thread_id": "thread-1",
+ "size_bytes": 500,
+ "from": "a@x.com",
+ "to": ["b@x.com"],
+ "cc": ["b@x.com"],
+ }]
+
+
+def test_get_organization_structure_maps_users_to_orgunits():
+ c = GmailConnector(admin_client=FakeAdmin(), gmail_client=FakeGmail(),
+ domain="x.com")
+ org = c.get_organization_structure()
+ assert org["user_orgunit"]["a@x.com"] == "/Sales"
+ assert org["org_users"] == {"a@x.com", "b@x.com"}
+
+
+def test_sync_emits_per_recipient_rows(tmp_path):
+ from src.connectors.gmail_store import GmailInteractionStore
+ store = GmailInteractionStore(db_path=str(tmp_path / "t.db"))
+ c = GmailConnector(admin_client=FakeAdmin(), gmail_client=FakeGmail(),
+ domain="x.com", store=store)
+ n = c.sync(start_ts=1000, end_ts=3000, sync_run_id="run1")
+ assert n == 2 # one To row + one Cc row
+ rows = store.query_window("x.com", 0, 10 ** 12)
+ kinds = sorted(r["recipient_kind"] for r in rows)
+ assert kinds == ["cc", "to"]
+ assert all(r["src_email"] == "a@x.com" and r["dst_email"] == "b@x.com"
+ for r in rows)
+ assert rows[0]["src_orgunit"] == "/Sales"
+ assert rows[0]["dst_orgunit"] == "/IT"
+
+
+def test_authenticate_returns_false_on_missing_credentials():
+ c = GmailConnector()
+ assert c.authenticate({}) is False
+
+
+def test_sync_is_idempotent(tmp_path):
+ from src.connectors.gmail_store import GmailInteractionStore
+ store = GmailInteractionStore(db_path=str(tmp_path / "t.db"))
+ c = GmailConnector(admin_client=FakeAdmin(), gmail_client=FakeGmail(),
+ domain="x.com", store=store)
+ assert c.sync(1000, 3000, "run1") == 2
+ assert c.sync(1000, 3000, "run2") == 0 # same messages, no new rows
+ assert len(store.query_window("x.com", 0, 10 ** 12)) == 2
+
+
+def test_parse_metadata_extracts_display_name_addresses():
+ from src.connectors.gmail_connector import _parse_metadata
+ msg = {"internalDate": "2000000", "threadId": "t", "sizeEstimate": 10,
+ "payload": {"headers": [
+ {"name": "From", "value": "a@x.com"},
+ {"name": "To", "value": '"Doe, John"
, alice@x.com'},
+ {"name": "Cc", "value": ""}]}}
+ out = _parse_metadata(msg)
+ assert out["to"] == ["j@x.com", "alice@x.com"]
+ assert out["cc"] == []
+ assert out["ts_utc"] == 2000 # internalDate ms -> s
+
+
+def test_legacy_google_stub_points_to_new_connector():
+ # The legacy GoogleWorkspaceConnector must no longer fabricate a matrix; it
+ # should refuse and direct callers to src.connectors.GmailConnector.
+ import inspect
+ import pytest
+ from datetime import datetime
+ from src.cloud_connectors import GoogleWorkspaceConnector
+
+ src = inspect.getsource(GoogleWorkspaceConnector.get_flow_data)
+ assert "GmailConnector" in src, "legacy stub must reference the new connector"
+ assert "np.zeros" not in src, "legacy stub must not fabricate a matrix"
+
+ with pytest.raises(NotImplementedError):
+ GoogleWorkspaceConnector().get_flow_data(datetime(2026, 1, 1),
+ datetime(2026, 2, 1))
diff --git a/tests/connectors/test_gmail_store.py b/tests/connectors/test_gmail_store.py
new file mode 100644
index 0000000..8de820a
--- /dev/null
+++ b/tests/connectors/test_gmail_store.py
@@ -0,0 +1,65 @@
+import os
+import tempfile
+
+import pytest
+
+from src.connectors.gmail_store import GmailInteractionStore
+
+
+@pytest.fixture
+def store():
+ fd, path = tempfile.mkstemp(suffix=".db")
+ os.close(fd)
+ s = GmailInteractionStore(db_path=path)
+ yield s
+ os.remove(path)
+
+
+def _row(src, dst, ts, kind="to", thread="t1", size=100, so="/Sales", do="/IT"):
+ return {
+ "src_email": src, "dst_email": dst, "recipient_kind": kind,
+ "ts_utc": ts, "thread_id": thread, "size_bytes": size,
+ "src_orgunit": so, "dst_orgunit": do,
+ }
+
+
+def test_insert_and_query_all(store):
+ rows = [_row("a@x.com", "b@x.com", 1000), _row("a@x.com", "c@x.com", 2000)]
+ assert store.insert_rows("x.com", "run1", rows) == 2
+ got = store.query_window("x.com", start_ts=0, end_ts=9999)
+ assert len(got) == 2
+ assert {r["dst_email"] for r in got} == {"b@x.com", "c@x.com"}
+
+
+def test_query_window_filters_by_time(store):
+ store.insert_rows("x.com", "run1", [
+ _row("a@x.com", "b@x.com", 1000),
+ _row("a@x.com", "b@x.com", 5000),
+ ])
+ got = store.query_window("x.com", start_ts=3000, end_ts=9999)
+ assert len(got) == 1
+ assert got[0]["ts_utc"] == 5000
+
+
+def test_query_window_scopes_by_org(store):
+ store.insert_rows("x.com", "run1", [_row("a@x.com", "b@x.com", 1000)])
+ store.insert_rows("y.com", "run2", [_row("a@y.com", "b@y.com", 1000)])
+ assert len(store.query_window("x.com", 0, 9999)) == 1
+ assert len(store.query_window("y.com", 0, 9999)) == 1
+
+
+def test_insert_empty_returns_zero(store):
+ assert store.insert_rows("x.com", "run1", []) == 0
+
+
+def test_insert_is_idempotent(store):
+ rows = [_row("a@x.com", "b@x.com", 1000)]
+ assert store.insert_rows("x.com", "run1", rows) == 1
+ assert store.insert_rows("x.com", "run2", rows) == 0 # same edge, ignored
+ assert len(store.query_window("x.com", 0, 9999)) == 1
+
+
+def test_schema_has_no_content_columns(store):
+ cols = store.column_names()
+ forbidden = {"subject", "body", "snippet", "content", "text"}
+ assert forbidden.isdisjoint(cols), f"metadata-only violated: {cols & forbidden}"
diff --git a/tests/connectors/test_gmail_weighting.py b/tests/connectors/test_gmail_weighting.py
new file mode 100644
index 0000000..08beb56
--- /dev/null
+++ b/tests/connectors/test_gmail_weighting.py
@@ -0,0 +1,114 @@
+import math
+
+import pytest
+
+from src.connectors.gmail_weighting import build_flow_matrix
+from src.network_ingestion import NetworkIngestionError
+
+DAY = 86400
+WEEK = 7 * DAY
+NOW = 1_000_000_000 # fixed reference; never read from the clock
+
+ORG = {"a@x.com", "b@x.com", "c@x.com"}
+
+
+def _row(src, dst, ts, kind="to", so="/Sales", do="/IT"):
+ return {"src_email": src, "dst_email": dst, "recipient_kind": kind,
+ "ts_utc": ts, "thread_id": "t", "size_bytes": 100,
+ "src_orgunit": so, "dst_orgunit": do}
+
+
+def _weight(result_pair, src, dst):
+ parsed, _ = result_pair
+ i = parsed.node_names.index(src)
+ j = parsed.node_names.index(dst)
+ return parsed.flow_matrix[i][j]
+
+
+def test_decay_half_life():
+ # One fresh message vs one message exactly half_life old.
+ rows = [_row("a@x.com", "b@x.com", NOW),
+ _row("a@x.com", "c@x.com", NOW - 30 * DAY)]
+ res = build_flow_matrix(rows, org_users=ORG, now_utc=NOW,
+ window_seconds=365 * DAY, half_life_seconds=30 * DAY,
+ beta=0.0, granularity="individual")
+ fresh = _weight(res, "a@x.com", "b@x.com")
+ aged = _weight(res, "a@x.com", "c@x.com")
+ assert math.isclose(aged, fresh * 0.5, rel_tol=1e-6)
+
+
+def test_sustained_rewards_distinct_weeks():
+ # Pair A->B: 2 messages in the SAME week. Pair A->C: 2 messages in DIFFERENT weeks.
+ # With beta>0 and decay off (half_life huge), A->C outweighs A->B.
+ big = 10 ** 9
+ rows = [
+ _row("a@x.com", "b@x.com", NOW - 1 * DAY),
+ _row("a@x.com", "b@x.com", NOW - 2 * DAY),
+ _row("a@x.com", "c@x.com", NOW - 1 * DAY),
+ _row("a@x.com", "c@x.com", NOW - 2 * WEEK),
+ ]
+ res = build_flow_matrix(rows, org_users=ORG, now_utc=NOW,
+ window_seconds=365 * DAY, half_life_seconds=big,
+ beta=1.0, granularity="individual")
+ assert _weight(res, "a@x.com", "c@x.com") > _weight(res, "a@x.com", "b@x.com")
+
+
+def test_department_granularity_sums_individual_flows():
+ # Two senders in /Sales both email /IT; department matrix aggregates them.
+ org = {"a@x.com", "b@x.com", "z@x.com"}
+ rows = [
+ _row("a@x.com", "z@x.com", NOW, so="/Sales", do="/IT"),
+ _row("b@x.com", "z@x.com", NOW, so="/Sales", do="/IT"),
+ ]
+ res = build_flow_matrix(rows, org_users=org, now_utc=NOW,
+ window_seconds=365 * DAY, half_life_seconds=10 ** 9,
+ beta=0.0, granularity="department")
+ parsed, _ = res
+ assert set(parsed.node_names) == {"Sales", "IT"}
+ i = parsed.node_names.index("Sales")
+ j = parsed.node_names.index("IT")
+ assert math.isclose(parsed.flow_matrix[i][j], 2.0, rel_tol=1e-6)
+
+
+def test_external_recipients_dropped_and_counted():
+ rows = [
+ _row("a@x.com", "b@x.com", NOW), # internal
+ _row("a@x.com", "outsider@other.com", NOW), # external -> dropped
+ ]
+ parsed, dropped = build_flow_matrix(
+ rows, org_users=ORG, now_utc=NOW, window_seconds=365 * DAY,
+ half_life_seconds=10 ** 9, beta=0.0, granularity="individual")
+ assert dropped == 1
+ assert "outsider@other.com" not in parsed.node_names
+
+
+def test_window_excludes_old_messages():
+ rows = [
+ _row("a@x.com", "b@x.com", NOW - 10 * DAY), # in 30d window
+ _row("a@x.com", "c@x.com", NOW - 100 * DAY), # outside 30d window
+ ]
+ parsed, _ = build_flow_matrix(
+ rows, org_users=ORG, now_utc=NOW, window_seconds=30 * DAY,
+ half_life_seconds=10 ** 9, beta=0.0, granularity="individual")
+ assert "c@x.com" not in parsed.node_names
+
+
+def test_zero_half_life_raises():
+ with pytest.raises(ValueError):
+ build_flow_matrix([_row("a@x.com", "b@x.com", NOW)], org_users=ORG,
+ now_utc=NOW, window_seconds=DAY, half_life_seconds=0,
+ beta=0.0, granularity="individual")
+
+
+def test_negative_beta_raises():
+ with pytest.raises(ValueError):
+ build_flow_matrix([_row("a@x.com", "b@x.com", NOW)], org_users=ORG,
+ now_utc=NOW, window_seconds=DAY, half_life_seconds=DAY,
+ beta=-1.0, granularity="individual")
+
+
+def test_empty_rows_raise_ingestion_error():
+ # No edges -> build_flow_matrix_from_edges rejects <2 nodes. Documents the contract.
+ with pytest.raises(NetworkIngestionError):
+ build_flow_matrix([], org_users=ORG, now_utc=NOW, window_seconds=DAY,
+ half_life_seconds=DAY, beta=0.0, granularity="individual")
diff --git a/tests/test_app_report_robustness.py b/tests/test_app_report_robustness.py
new file mode 100644
index 0000000..ff55888
--- /dev/null
+++ b/tests/test_app_report_robustness.py
@@ -0,0 +1,324 @@
+"""
+Regression tests for the post-refactor crash sweep + scale-aware guard.
+
+Covers three failure classes that crashed the Streamlit analysis/report paths
+after the precompute / gradient-reframe / metric-sentinel refactor:
+
+ 1. Brittle ``metrics['key']`` bracket access when the app's passed dict lacks
+ the key (e.g. tier-2 cache-reconstruction has no ``viability_lower_bound``).
+ 2. ``:.Nf`` formatting applied to sentinel strings ('insufficient',
+ 'skipped_large_graph', 'not_computed_large_graph') or None.
+ 3. JSON-stringified node-index keys breaking ``node_names[node_id]`` lookups.
+
+Plus the PART-2 scale guard: ``AdvancedNetworkAnalyzer.get_all_metrics()`` must
+complete quickly on a 300-node graph and report ``computation_mode='approximate'``.
+
+The Streamlit display functions are exercised through a fake ``streamlit`` module
+whose calls are no-ops but STILL evaluate their arguments, so f-string
+formatting / KeyError / NameError surface as real exceptions.
+"""
+import os
+import sys
+import time
+import types
+import json
+
+import numpy as np
+import pytest
+
+# --------------------------------------------------------------------------
+# Fake streamlit: no-op UI, but arguments are still evaluated.
+# Installed BEFORE importing app. No other test imports streamlit, and none of
+# the report/network modules import it, so this does not pollute the suite.
+# --------------------------------------------------------------------------
+
+class _Ctx:
+ def __enter__(self):
+ return self
+ def __exit__(self, *a):
+ return False
+ def __call__(self, *a, **k):
+ return self
+ def __getattr__(self, name):
+ return _Ctx()
+ def __iter__(self):
+ return iter([])
+
+
+class _SessionState(dict):
+ def __getattr__(self, name):
+ try:
+ return self[name]
+ except KeyError:
+ raise AttributeError(name)
+ def __setattr__(self, name, value):
+ self[name] = value
+ def get(self, name, default=None):
+ return dict.get(self, name, default)
+
+
+class _FakeStreamlit(types.ModuleType):
+ def __init__(self, name):
+ super().__init__(name)
+ self.session_state = _SessionState()
+ self.sidebar = _Ctx()
+ self.column_config = _Ctx()
+
+ def columns(self, spec, **k):
+ n = spec if isinstance(spec, int) else len(spec)
+ return [_Ctx() for _ in range(n)]
+
+ def tabs(self, labels, **k):
+ return [_Ctx() for _ in range(len(labels))]
+
+ def container(self, *a, **k): return _Ctx()
+ def expander(self, *a, **k): return _Ctx()
+ def spinner(self, *a, **k): return _Ctx()
+ def form(self, *a, **k): return _Ctx()
+ def status(self, *a, **k): return _Ctx()
+ def popover(self, *a, **k): return _Ctx()
+ def empty(self, *a, **k): return _Ctx()
+ def progress(self, *a, **k): return _Ctx()
+ def set_page_config(self, *a, **k): return None
+
+ def cache_data(self, *a, **k):
+ if len(a) == 1 and callable(a[0]) and not k:
+ return a[0]
+ return lambda fn: fn
+
+ def cache_resource(self, *a, **k):
+ if len(a) == 1 and callable(a[0]) and not k:
+ return a[0]
+ return lambda fn: fn
+
+ def button(self, *a, **k): return False
+ def download_button(self, *a, **k): return False
+ def checkbox(self, *a, **k): return bool(k.get('value', False))
+ def toggle(self, *a, **k): return bool(k.get('value', False))
+
+ def radio(self, label, options=(), index=0, **k):
+ try:
+ return list(options)[index or 0]
+ except Exception:
+ return None
+
+ def selectbox(self, label, options=(), index=0, **k):
+ try:
+ return list(options)[index or 0]
+ except Exception:
+ return None
+
+ def multiselect(self, label, options=(), default=None, **k):
+ return list(default) if default else []
+
+ def slider(self, label, min_value=0, max_value=100, value=None, **k):
+ return value if value is not None else min_value
+
+ def number_input(self, label, min_value=None, max_value=None, value=0, **k):
+ return value if value is not None else (min_value or 0)
+
+ def text_input(self, *a, **k): return k.get('value', '')
+ def text_area(self, *a, **k): return k.get('value', '')
+ def file_uploader(self, *a, **k): return None
+ def color_picker(self, *a, **k): return k.get('value', '#000000')
+ def date_input(self, *a, **k): return None
+ def rerun(self, *a, **k): return None
+ def stop(self, *a, **k): return None
+
+ def __getattr__(self, name):
+ return lambda *a, **k: None
+
+
+def _install_fake_streamlit():
+ mod = _FakeStreamlit('streamlit')
+ comp = types.ModuleType('streamlit.components')
+ v1 = types.ModuleType('streamlit.components.v1')
+ v1.html = lambda *a, **k: None
+ v1.iframe = lambda *a, **k: None
+ v1.declare_component = lambda *a, **k: (lambda *aa, **kk: None)
+ comp.v1 = v1
+ mod.components = comp
+ sys.modules['streamlit'] = mod
+ sys.modules['streamlit.components'] = comp
+ sys.modules['streamlit.components.v1'] = v1
+ return mod
+
+
+REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+for p in (REPO, os.path.join(REPO, 'src')):
+ if p not in sys.path:
+ sys.path.insert(0, p)
+
+_st = _install_fake_streamlit()
+
+import app # noqa: E402 (import after fake streamlit is installed)
+from ulanowicz_calculator import UlanowiczCalculator # noqa: E402
+from network_analyzer import AdvancedNetworkAnalyzer # noqa: E402
+from publication_report import PublicationReportGenerator # noqa: E402
+from database.full_profile import precompute_full_profile # noqa: E402
+from database.precompute_pipeline import get_precompute_pipeline # noqa: E402
+
+SMALL = os.path.join(REPO, 'data/ecosystem_samples/cone_spring_original.json')
+LARGE = os.path.join(REPO, 'data/ecosystem_samples/enzyme_network.json')
+
+
+def _load(path):
+ with open(path) as f:
+ d = json.load(f)
+ fm = np.asarray(d.get('flow_matrix', d.get('flows')), dtype=np.float64)
+ nn = d.get('node_names', d.get('nodes')) or [f'N{i}' for i in range(fm.shape[0])]
+ name = d.get('org_name', d.get('name', os.path.basename(path)))
+ return fm, list(nn), name
+
+
+def _build_data(path):
+ """Mirror the app's cache-RECONSTRUCTION path (the risky one): metrics come
+ from fresh tier-2 vectorized metrics (no viability bounds), plus the SI/ELD/
+ TD fills and is_viable the app adds โ deliberately NOT the fuller
+ get_extended_metrics(), to exercise the missing-key code paths."""
+ fm, nn, name = _load(path)
+ pipeline = get_precompute_pipeline()
+ profile = precompute_full_profile(fm, nn, org_name=name)
+ _st.session_state['full_profile'] = profile
+ _st.session_state['analysis_data'] = {
+ 'flow_matrix': fm, 'node_names': nn, 'org_name': name,
+ }
+
+ calc = UlanowiczCalculator(fm, nn, use_vectorized=True)
+ em = dict(pipeline._get_vectorized_metrics(fm, nn))
+ if 'is_viable' not in em:
+ alpha = em.get('relative_ascendency', 0)
+ em['is_viable'] = 0.2 <= alpha <= 0.6
+ core = profile.get('core', {})
+ for key, method in (
+ ('structural_information', 'calculate_structural_information'),
+ ('effective_link_density', 'calculate_effective_link_density'),
+ ('trophic_depth', 'calculate_trophic_depth'),
+ ):
+ if key not in em or em.get(key, 0) == 0:
+ stored = core.get(key)
+ em[key] = stored if (stored is not None and stored != 0) else getattr(calc, method)()
+ assess = calc.assess_regenerative_health()
+ oasis = profile.get('oasis') if isinstance(profile.get('oasis'), dict) and \
+ 'dimension_scores' in profile.get('oasis', {}) else None
+ return dict(calc=calc, em=em, assess=assess, name=name, fm=fm, nn=nn, oasis=oasis)
+
+
+# --------------------------------------------------------------------------
+# PART 1 โ display functions run clean for small AND large orgs
+# --------------------------------------------------------------------------
+
+@pytest.mark.parametrize('path', [SMALL, LARGE])
+def test_display_functions_run_clean(path):
+ d = _build_data(path)
+ app.display_core_metrics_combined(d['em'], d['assess'], d['name'], d['fm'], d['nn'])
+ app.display_visual_summary_cards(d['em'], d['assess'])
+ app.display_network_analysis(d['calc'], d['em'], d['fm'], d['nn'])
+ app.display_oasis_health(d['calc'], d['em'], d['fm'], d['nn'], d['name'])
+ app.display_detailed_report(d['calc'], d['em'], d['assess'], d['name'])
+
+
+# --------------------------------------------------------------------------
+# PART 1b โ both report generators for small AND large org
+# --------------------------------------------------------------------------
+
+@pytest.mark.parametrize('path', [SMALL, LARGE])
+def test_report_generators(path):
+ from src.pdf_generator import generate_pdf_report
+ d = _build_data(path)
+ gen = PublicationReportGenerator(
+ calculator=d['calc'], metrics=d['em'], assessments=d['assess'],
+ org_name=d['name'], flow_matrix=d['fm'], node_names=d['nn'],
+ oasis_profile=d['oasis'])
+ report = gen.generate_full_report()
+ assert isinstance(report, str) and len(report) > 500
+ # Key sections present.
+ for marker in ('RESULTS', 'RECOMMENDATIONS'):
+ assert marker in report.upper()
+ pdf = generate_pdf_report(gen, d['calc'], d['em'], {})
+ assert pdf is not None and len(pdf) > 0
+
+
+def test_report_missing_viability_bounds_does_not_crash():
+ """The exact KeyError('viability_lower_bound') regression: reconstruction
+ metrics lack the viability bounds; the report must backfill, not crash."""
+ fm, nn, name = _load(SMALL)
+ calc = UlanowiczCalculator(fm, nn, use_vectorized=True)
+ minimal = {
+ 'ascendency_ratio': 0.35, 'relative_ascendency': 0.35, 'robustness': 0.4,
+ 'redundancy': 0.5, 'overhead': 100.0, 'overhead_ratio': 0.6,
+ 'network_efficiency': 0.35, 'ascendency': 60.0, 'development_capacity': 160.0,
+ 'flow_diversity': 2.5, 'total_system_throughput': 100.0,
+ } # deliberately NO viability_lower_bound / viability_upper_bound / is_viable
+ gen = PublicationReportGenerator(
+ calculator=calc, metrics=minimal, assessments={}, org_name=name,
+ flow_matrix=fm, node_names=nn)
+ report = gen.generate_full_report()
+ assert isinstance(report, str) and len(report) > 500
+ assert gen.metrics['viability_lower_bound'] == pytest.approx(0.2)
+ assert gen.metrics['viability_upper_bound'] == pytest.approx(0.6)
+
+
+# --------------------------------------------------------------------------
+# PART 2 โ scale-aware guard
+# --------------------------------------------------------------------------
+
+def _random_flow_matrix(n, density=0.03, seed=7):
+ rng = np.random.default_rng(seed)
+ m = rng.random((n, n))
+ fm = np.where(m < density, rng.random((n, n)) * 10, 0.0)
+ np.fill_diagonal(fm, 0.0)
+ return fm
+
+
+def test_get_all_metrics_large_graph_is_approximate_and_fast():
+ n = 300
+ fm = _random_flow_matrix(n)
+ nn = [f'N{i}' for i in range(n)]
+ analyzer = AdvancedNetworkAnalyzer(fm, nn)
+ t0 = time.time()
+ metrics = analyzer.get_all_metrics()
+ elapsed = time.time() - t0
+ assert elapsed < 20.0, f"get_all_metrics on {n} nodes took {elapsed:.1f}s"
+ assert metrics['computation_mode'] == 'approximate'
+ assert 'betweenness_centrality' in metrics['approximated_metrics']
+ # Sentinels present for the skipped metrics.
+ assert metrics['small_world']['small_world_sigma'] == 'not_computed_large_graph'
+ assert metrics['rich_club']['rich_club_coefficient'] == 'skipped_large_graph'
+
+
+def test_small_graph_is_full_mode():
+ fm, nn, _ = _load(SMALL)
+ analyzer = AdvancedNetworkAnalyzer(fm, nn)
+ metrics = analyzer.get_all_metrics()
+ assert metrics['computation_mode'] == 'full'
+ assert metrics['approximated_metrics'] == []
+
+
+def test_summary_report_handles_sentinels():
+ """get_summary_report must format sentinel small-world/rich-club values as
+ text, never raising on ':.2f'."""
+ fm = _random_flow_matrix(300)
+ nn = [f'N{i}' for i in range(300)]
+ analyzer = AdvancedNetworkAnalyzer(fm, nn)
+ text = analyzer.get_summary_report() # would ValueError if sentinels hit :.2f
+ assert 'not_computed_large_graph' in text
+
+
+def test_safe_fmt_helper():
+ assert app._safe_fmt(0.12345) == '0.12'
+ assert app._safe_fmt(0.12345, '.3f') == '0.123'
+ assert app._safe_fmt('insufficient') == 'insufficient'
+ assert app._safe_fmt('skipped_large_graph') == 'skipped_large_graph'
+ assert app._safe_fmt(None) == 'N/A'
+ assert app._safe_fmt(True) == 'True' # bool is not treated as a number
+
+
+def test_coerce_int_keys_and_node_label():
+ d = {'0': 0.5, '1': 0.3, 'x': 0.1}
+ coerced = app._coerce_int_keys(d)
+ assert coerced[0] == 0.5 and coerced[1] == 0.3 and coerced['x'] == 0.1
+ node_names = ['A', 'B', 'C']
+ assert app._node_label(node_names, '2') == 'C'
+ assert app._node_label(node_names, 1) == 'B'
+ assert app._node_label(node_names, 'missing') == 'missing'
diff --git a/tests/test_ena_fixes.py b/tests/test_ena_fixes.py
new file mode 100644
index 0000000..b1df542
--- /dev/null
+++ b/tests/test_ena_fixes.py
@@ -0,0 +1,271 @@
+"""
+Track-1 ENA-method formula corrections โ test-driven.
+
+These tests encode the paper-expected behavior for the four standard-backed
+corrections confirmed by the expert panel (see
+docs/business-revision/evidence/expert-ena-methods.md and
+validation-CD-roles-cycling.md):
+
+ FIX 1 โ Effective connectivity must be F/N (>= 1), not the inverted N/F.
+ Zorach & Ulanowicz (2003) p.72: C = F/N; identity R = F/Cยฒ.
+ FIX 2 โ Finn Cycling Index via column-normalized Leontief inverse
+ (Finn 1976; Ulanowicz 2004 ยง5). Pure ring -> ~1, chain -> 0.
+ The old short-cycle proxy returns ~0 on a pure ring.
+ FIX 3 โ Trophic level must be flow-weighted (Levine 1980; Ulanowicz 2004 ยง4),
+ producing fractional effective levels, not unweighted shortest-path hops.
+ FIX 4 โ "Lindeman efficiency" relabeled to respiratory_retention_ratio.
+"""
+import os
+import sys
+
+import numpy as np
+import pytest
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
+
+from ulanowicz_calculator import UlanowiczCalculator
+from ecosystem_flow_calculator import EcosystemFlowCalculator
+from vectorized_metrics import vectorized_effective_connectivity
+
+
+def _random_flow_matrix(n, seed):
+ rng = np.random.default_rng(seed)
+ # positive off-diagonal flows, zero diagonal (typical directed flow network)
+ m = rng.uniform(1.0, 10.0, size=(n, n))
+ np.fill_diagonal(m, 0.0)
+ return m
+
+
+# ---------------------------------------------------------------------------
+# FIX 1 โ Effective connectivity = F/N
+# ---------------------------------------------------------------------------
+
+class TestFix1EffectiveConnectivity:
+
+ SEEDS = [(4, 1), (5, 3), (6, 9), (5, 42), (3, 7)]
+
+ @pytest.mark.parametrize("n,seed", SEEDS)
+ def test_connectivity_floor_geq_one(self, n, seed):
+ """Connectivity is flows-per-node and must be >= 1.0 for a connected
+ network (Ulanowicz 2004 p.334: lower window bound = 1.0)."""
+ m = _random_flow_matrix(n, seed)
+ calc = UlanowiczCalculator(m, use_vectorized=False)
+ c = calc.calculate_effective_connectivity()
+ assert c >= 1.0 - 1e-9, f"connectivity {c} < 1.0 (n={n}, seed={seed})"
+
+ @pytest.mark.parametrize("n,seed", SEEDS)
+ def test_connectivity_equals_F_over_N(self, n, seed):
+ """C == F/N to 1e-9 (Zorach & Ulanowicz 2003 p.72)."""
+ m = _random_flow_matrix(n, seed)
+ calc = UlanowiczCalculator(m, use_vectorized=False)
+ c = calc.calculate_effective_connectivity()
+ f = calc.calculate_effective_flows()
+ nn = calc.calculate_effective_nodes()
+ assert c == pytest.approx(f / nn, abs=1e-9)
+
+ @pytest.mark.parametrize("n,seed", SEEDS)
+ def test_identity_R_equals_F_over_C_squared(self, n, seed):
+ """R = exp(AMI) must equal F/Cยฒ to 1e-9 (Z-U 2003 identity block)."""
+ m = _random_flow_matrix(n, seed)
+ calc = UlanowiczCalculator(m, use_vectorized=False)
+ c = calc.calculate_effective_connectivity()
+ f = calc.calculate_effective_flows()
+ r = np.exp(calc.calculate_ami())
+ assert r == pytest.approx(f / c**2, abs=1e-9)
+
+ @pytest.mark.parametrize("n,seed", SEEDS)
+ def test_loop_matches_vectorized(self, n, seed):
+ """Loop and vectorized effective connectivity agree to 1e-9."""
+ m = _random_flow_matrix(n, seed)
+ loop = UlanowiczCalculator(m, use_vectorized=False).calculate_effective_connectivity()
+ calc_v = UlanowiczCalculator(m, use_vectorized=True)
+ vec = vectorized_effective_connectivity(
+ calc_v.flow_matrix,
+ calc_v.output_throughput,
+ calc_v.input_throughput,
+ calc_v._tst,
+ )
+ assert loop == pytest.approx(vec, abs=1e-9)
+
+
+# ---------------------------------------------------------------------------
+# FIX 2 โ Finn Cycling Index (canonical Leontief) + short-cycle proxy relabel
+# ---------------------------------------------------------------------------
+
+class TestFix2FinnCyclingIndex:
+
+ @staticmethod
+ def _ring4():
+ # 1->2->3->4->1, unit flows
+ m = np.zeros((4, 4))
+ m[0, 1] = m[1, 2] = m[2, 3] = m[3, 0] = 1.0
+ return m
+
+ @staticmethod
+ def _chain4():
+ # 1->2->3->4, acyclic
+ m = np.zeros((4, 4))
+ m[0, 1] = m[1, 2] = m[2, 3] = 1.0
+ return m
+
+ def test_full_finn_ring_is_fully_cycled(self):
+ """A pure directed ring recycles ~100% of its medium: FCI ~= 1."""
+ calc = EcosystemFlowCalculator(self._ring4())
+ fci = calc.calculate_finn_cycling_index()
+ assert fci == pytest.approx(1.0, abs=0.05), f"ring FCI={fci}"
+
+ def test_full_finn_chain_is_acyclic(self):
+ """An acyclic chain has no cycling: FCI ~= 0."""
+ calc = EcosystemFlowCalculator(self._chain4())
+ fci = calc.calculate_finn_cycling_index()
+ assert fci == pytest.approx(0.0, abs=0.05), f"chain FCI={fci}"
+
+ def test_short_cycle_proxy_returns_zero_on_ring(self):
+ """The short-cycle proxy (self-loops + 2-cycles only) misses the
+ length-4 cycle and returns ~0 โ documenting why it's only a proxy."""
+ calc = UlanowiczCalculator(self._ring4(), use_vectorized=False)
+ proxy = calc.calculate_short_cycle_proxy()
+ assert proxy == pytest.approx(0.0, abs=1e-9), f"proxy={proxy}"
+
+ def test_short_cycle_proxy_backcompat_alias(self):
+ """The old method name must still exist and equal the proxy."""
+ calc = UlanowiczCalculator(self._ring4(), use_vectorized=False)
+ assert calc.calculate_finn_cycling_index() == pytest.approx(
+ calc.calculate_short_cycle_proxy(), abs=1e-12
+ )
+
+ def test_full_finn_available_on_ulanowicz_calculator(self):
+ """The corrected full Finn (internal-only) is available and returns
+ ~1 on the ring, unlike the short-cycle proxy."""
+ calc = UlanowiczCalculator(self._ring4(), use_vectorized=False)
+ full = calc.calculate_finn_cycling_index_full()
+ assert full == pytest.approx(1.0, abs=0.05), f"full FCI={full}"
+
+ def test_full_finn_consistent_basis_with_imports_and_cycle(self):
+ """Regression: numerator (TSTc) and denominator (TST) must use the SAME
+ throughflow basis โ total throughflow T_i = internal inflow + imports โ
+ per Finn 1976 / Ulanowicz 2004 ยง5. If the denominator uses internal-only
+ TST while TSTc is weighted by total throughflow, FCI is biased upward
+ for networks that have BOTH large imports AND real cycling.
+
+ Hand-computed canonical network (3-node cycle 1->2->3->1 with an import
+ into node 1 and an export leak out of node 3, at steady state):
+
+ Internal flows: 1->2 = 10, 2->3 = 10, 3->1 = 6
+ Import: 4 into node 1
+ Export: 4 out of node 3
+ => every node has total throughflow T_i = 10 (in = out).
+
+ Column-normalize by total inflow T_j = 10:
+ S = (I - G)^-1 has diagonal s_ii = 2.5 for all i,
+ cycled fraction (s_ii - 1)/s_ii = 0.6 for all i.
+ TSTc = ฮฃ 0.6 * 10 = 18.0
+ TST = ฮฃ T_i = 30.0 (total throughflow, NOT internal-only 26.0)
+ FCI = 18.0 / 30.0 = 0.6 (reconciled basis)
+
+ The mismatched basis (TSTc / internal-TST) would give 18/26 = 0.6923.
+ """
+ T = np.zeros((3, 3))
+ T[0, 1] = 10.0 # 1 -> 2
+ T[1, 2] = 10.0 # 2 -> 3
+ T[2, 0] = 6.0 # 3 -> 1 (cycle back)
+ imports = np.array([4.0, 0.0, 0.0])
+ exports = np.array([0.0, 0.0, 4.0])
+ calc = EcosystemFlowCalculator(T, imports=imports, exports=exports)
+ fci = calc.calculate_finn_cycling_index()
+ assert fci == pytest.approx(0.6, abs=1e-9), f"FCI={fci} (expected 0.6)"
+
+
+# ---------------------------------------------------------------------------
+# FIX 3 โ Flow-weighted effective trophic level (Levine)
+# ---------------------------------------------------------------------------
+
+class TestFix3TrophicDepth:
+
+ @staticmethod
+ def _chain4():
+ m = np.zeros((4, 4))
+ m[0, 1] = m[1, 2] = m[2, 3] = 1.0
+ return m
+
+ def test_effective_levels_increase_along_chain(self):
+ """On a linear chain, effective trophic levels increase 1,2,3,4."""
+ calc = UlanowiczCalculator(self._chain4(), use_vectorized=False)
+ levels = calc.calculate_effective_trophic_levels()
+ assert levels[0] < levels[1] < levels[2] < levels[3]
+
+ def test_effective_levels_can_be_fractional(self):
+ """Levine effective levels are flow-weighted and can be fractional โ
+ the Ulanowicz 2004 worked example yields 2.5 for a mixed feeder."""
+ # Compartment 4 fed 60% from L1, 30% from L2(=via 2), 10% from L3(=via 3)
+ # Build a network reproducing the 2.5 example (Ulanowicz 2004 Fig.4).
+ m = np.zeros((4, 4))
+ m[0, 1] = 1.0 # 1 -> 2 (level 2)
+ m[1, 2] = 1.0 # 2 -> 3 (level 3)
+ # node 4 (index 3) is fed 0.6 from 1, 0.3 from 2, 0.1 from 3
+ m[0, 3] = 0.6
+ m[1, 3] = 0.3
+ m[2, 3] = 0.1
+ calc = UlanowiczCalculator(m, use_vectorized=False)
+ levels = calc.calculate_effective_trophic_levels()
+ assert levels[3] == pytest.approx(2.5, abs=1e-6), f"levels={levels}"
+ # fractional (not an integer hop count)
+ assert abs(levels[3] - round(levels[3])) > 1e-6
+
+ def test_trophic_depth_differs_from_shortest_path(self):
+ """The flow-weighted depth must differ from the unweighted shortest
+ path on a flow-weighted example."""
+ m = np.zeros((4, 4))
+ m[0, 1] = 1.0
+ m[1, 2] = 1.0
+ m[0, 3] = 0.6
+ m[1, 3] = 0.3
+ m[2, 3] = 0.1
+ calc = UlanowiczCalculator(m, use_vectorized=False)
+ depth = calc.calculate_trophic_depth()
+ # flow-weighted depth (max effective level ~3) != unweighted mean hops
+ assert depth == pytest.approx(3.0, abs=1e-6), f"depth={depth}"
+
+
+# ---------------------------------------------------------------------------
+# FIX 4 โ "Lindeman efficiency" relabel -> respiratory_retention_ratio
+# ---------------------------------------------------------------------------
+
+class TestFix4RespiratoryRetention:
+
+ @staticmethod
+ def _calc():
+ m = np.zeros((3, 3))
+ m[0, 1] = 5.0
+ m[1, 2] = 3.0
+ imports = np.array([10.0, 0.0, 0.0])
+ exports = np.array([0.0, 0.0, 2.0])
+ respiration = np.array([1.0, 1.0, 1.0])
+ return EcosystemFlowCalculator(m, imports=imports, exports=exports,
+ respiration=respiration)
+
+ def test_respiratory_retention_ratio_value(self):
+ """Renamed metric equals the documented formula
+ 1 - respiration/(TST + imports)."""
+ calc = self._calc()
+ tst = calc.calculate_tst()
+ expected = 1 - (np.sum(calc.respiration) / (tst + np.sum(calc.imports)))
+ expected = max(0.0, min(1.0, expected))
+ got = calc.calculate_respiratory_retention_ratio()
+ assert got == pytest.approx(expected, abs=1e-12)
+
+ def test_metric_key_present_in_output(self):
+ """The renamed key is present in ecosystem metrics."""
+ calc = self._calc()
+ metrics = calc.get_ecosystem_metrics()
+ assert 'respiratory_retention_ratio' in metrics
+
+ def test_lindeman_backcompat_alias(self):
+ """Old key/method preserved as a back-compat alias so consumers
+ don't break."""
+ calc = self._calc()
+ assert calc.calculate_lindeman_efficiency() == pytest.approx(
+ calc.calculate_respiratory_retention_ratio(), abs=1e-12
+ )
+ metrics = calc.get_ecosystem_metrics()
+ assert 'lindeman_efficiency' in metrics
diff --git a/tests/test_esg_crosswalk.py b/tests/test_esg_crosswalk.py
new file mode 100644
index 0000000..6761072
--- /dev/null
+++ b/tests/test_esg_crosswalk.py
@@ -0,0 +1,190 @@
+"""
+Tests for the FINDING-SPECIFIC ESG framework crosswalk.
+
+The crosswalk is an INDICATIVE structural-lens mapping from OASIS dimensions to
+GRI / ESRS-CSRD / TCFD disclosure areas โ NOT a compliance attestation. These
+tests pin the substantive upgrade from the old one-to-one code lookup:
+per-dimension framework list, a disclosure-relevance sentence, and a
+status-driven materiality flag; plus the fix/caveat of the previously-stretched
+SUSTAINABLE -> climate-financial mapping.
+"""
+import numpy as np
+
+from src import report_intelligence as ri
+
+DIMS = ['OPEN', 'AUTONOMOUS', 'SYMBIOTIC', 'INTELLIGENT', 'SUSTAINABLE']
+
+
+def _profile(statuses=None):
+ statuses = statuses or {'open': 'HEALTHY', 'autonomous': 'WARNING',
+ 'symbiotic': 'HEALTHY', 'intelligent': 'WARNING',
+ 'sustainable': 'HEALTHY'}
+ return {
+ 'dimension_scores': {'open': 70, 'autonomous': 55, 'symbiotic': 80,
+ 'intelligent': 60, 'sustainable': 78},
+ 'dimension_status': dict(statuses),
+ 'dimension_details': {'sustainable': {'metrics': {
+ 'relative_ascendency': 0.42, 'robustness': 0.36, 'is_viable': True}}},
+ 'overall_score': 70.0, 'overall_status': 'HEALTHY',
+ }
+
+
+def _metrics(alpha=0.42):
+ return {'ascendency_ratio': alpha, 'robustness': 0.36,
+ 'overhead_ratio': 1 - alpha, 'redundancy': 0.5}
+
+
+# --- Structure: every dimension is substantively populated -------------------
+
+def test_covers_all_five_dimensions():
+ rows = ri.build_esg_crosswalk(_profile(), _metrics())
+ assert {r['oasis_dimension'] for r in rows} == set(DIMS)
+
+
+def test_each_dimension_has_frameworks_relevance_and_materiality():
+ rows = ri.build_esg_crosswalk(_profile(), _metrics())
+ for r in rows:
+ # >= 1 framework mapping, each with a standard + code
+ assert isinstance(r['frameworks'], list) and len(r['frameworks']) >= 1
+ for fw in r['frameworks']:
+ assert fw.get('standard') and fw.get('code')
+ # non-empty disclosure-relevance sentence
+ assert isinstance(r['disclosure_relevance'], str)
+ assert len(r['disclosure_relevance'].strip()) > 20
+ # materiality field driven by status
+ assert isinstance(r['materiality'], dict)
+ assert r['materiality'].get('flag')
+ assert r['materiality'].get('label')
+
+
+def test_frameworks_span_gri_esrs_tcfd():
+ rows = ri.build_esg_crosswalk(_profile(), _metrics())
+ for r in rows:
+ stds = {fw['standard'] for fw in r['frameworks']}
+ # each dimension must touch the three families (even if some are caveated)
+ assert {'GRI', 'ESRS', 'TCFD'} <= stds, f"{r['oasis_dimension']} missing a family"
+
+
+def test_backward_compatible_ref_strings_present():
+ # Existing report_intelligence tests / pdf path still read these keys.
+ rows = ri.build_esg_crosswalk(_profile(), _metrics())
+ for r in rows:
+ for k in ('oasis_dimension', 'finding_summary', 'gri_ref', 'esrs_ref', 'tcfd_ref'):
+ assert k in r and isinstance(r[k], str) and r[k]
+
+
+def test_handles_empty_profile():
+ rows = ri.build_esg_crosswalk({}, {})
+ assert len(rows) == 5
+ for r in rows:
+ assert r['materiality']['flag'] == 'not_assessed'
+
+
+# --- Materiality reflects the org's ACTUAL status ----------------------------
+
+def test_materiality_flags_critical_dimension_as_attention():
+ prof = _profile({'open': 'HEALTHY', 'autonomous': 'HEALTHY',
+ 'symbiotic': 'HEALTHY', 'intelligent': 'HEALTHY',
+ 'sustainable': 'CRITICAL'})
+ rows = ri.build_esg_crosswalk(prof, _metrics())
+ sust = next(r for r in rows if r['oasis_dimension'] == 'SUSTAINABLE')
+ assert sust['materiality']['flag'] == 'attention'
+ assert sust['materiality']['material'] is True
+ assert 'material' in sust['materiality']['label'].lower()
+
+
+def test_materiality_reads_healthy_as_supporting_evidence():
+ prof = _profile({'open': 'HEALTHY', 'autonomous': 'HEALTHY',
+ 'symbiotic': 'HEALTHY', 'intelligent': 'HEALTHY',
+ 'sustainable': 'HEALTHY'})
+ rows = ri.build_esg_crosswalk(prof, _metrics())
+ sust = next(r for r in rows if r['oasis_dimension'] == 'SUSTAINABLE')
+ assert sust['materiality']['flag'] == 'supporting'
+ assert sust['materiality']['material'] is False
+ assert 'supporting' in sust['materiality']['label'].lower()
+
+
+def test_materiality_warning_is_a_watch_signal():
+ prof = _profile({'open': 'WARNING', 'autonomous': 'HEALTHY',
+ 'symbiotic': 'HEALTHY', 'intelligent': 'HEALTHY',
+ 'sustainable': 'HEALTHY'})
+ rows = ri.build_esg_crosswalk(prof, _metrics())
+ op = next(r for r in rows if r['oasis_dimension'] == 'OPEN')
+ assert op['materiality']['flag'] == 'watch'
+
+
+# --- The audited STRETCH is fixed / caveated ---------------------------------
+
+def test_sustainable_no_longer_bare_climate_financial_mapping():
+ """R17 audit: SUSTAINABLE -> GRI 201-2 (climate financial implications) conflated
+ an information-theoretic balance metric with climate risk. It must be dropped, or
+ only referenced with an explicit contextual caveat."""
+ rows = ri.build_esg_crosswalk(_profile(), _metrics())
+ sust = next(r for r in rows if r['oasis_dimension'] == 'SUSTAINABLE')
+ for fw in sust['frameworks']:
+ code = fw['code'].lower()
+ if '201-2' in code or 'climate' in code:
+ assert fw.get('caveat'), "climate-financial mapping must carry a caveat"
+ # the relevance sentence must distinguish structural resilience from climate risk
+ rel = sust['disclosure_relevance'].lower()
+ assert 'climate' in rel and ('structural' in rel or 'network' in rel)
+
+
+def test_stretched_mappings_carry_a_caveat():
+ """Any framework flagged as a stretch/analogue must be explicitly caveated,
+ never presented as a direct disclosure."""
+ rows = ri.build_esg_crosswalk(_profile(), _metrics())
+ for r in rows:
+ for fw in r['frameworks']:
+ if fw.get('contextual'):
+ assert fw.get('caveat'), f"{r['oasis_dimension']} contextual mapping needs caveat"
+
+
+# --- The 'indicative / not attestation' caveat is rendered -------------------
+
+def test_module_caveat_is_defensible_language():
+ cav = ri.INDICATIVE_ESG_CAVEAT
+ assert 'indicative' in cav.lower()
+ assert 'not' in cav.lower()
+ assert 'attestation' in cav.lower() or 'compliance' in cav.lower()
+
+
+def test_every_row_carries_the_caveat():
+ rows = ri.build_esg_crosswalk(_profile(), _metrics())
+ for r in rows:
+ assert r['caveat'] == ri.INDICATIVE_ESG_CAVEAT
+
+
+def test_caveat_present_in_rendered_pdf_esg_section():
+ """The rendered ESG section of the app's actual PDF must carry the
+ indicative / not-a-compliance-attestation caveat."""
+ import io
+ from pypdf import PdfReader
+ from src.ulanowicz_calculator import UlanowiczCalculator
+ from src.publication_report import PublicationReportGenerator
+ from src.pdf_generator import generate_pdf_report
+
+ flow = np.array([
+ [0, 10, 0, 0, 5],
+ [0, 0, 8, 2, 0],
+ [0, 0, 0, 7, 1],
+ [3, 0, 0, 0, 6],
+ [0, 4, 0, 0, 0],
+ ], dtype=float)
+ nodes = ['A', 'B', 'C', 'D', 'E']
+ calc = UlanowiczCalculator(flow, nodes)
+ metrics = calc.get_extended_metrics()
+ assessments = calc.assess_regenerative_health()
+ rg = PublicationReportGenerator(
+ calculator=calc, metrics=metrics, assessments=assessments,
+ org_name='Test Org', flow_matrix=flow, node_names=nodes)
+ pdf = generate_pdf_report(rg, calc, metrics, charts=None)
+ reader = PdfReader(io.BytesIO(pdf))
+ text = "\n".join((p.extract_text() or "") for p in reader.pages)
+ assert 'ESG Framework Mapping' in text
+ # caveat phrasing
+ low = text.lower()
+ assert 'indicative' in low
+ assert 'not a compliance attestation' in low or 'not a compliance' in low
+ # richer content: a disclosure-relevance sentence and a materiality flag render
+ assert 'Materiality' in text or 'materiality' in low
diff --git a/tests/test_exec_onepager.py b/tests/test_exec_onepager.py
new file mode 100644
index 0000000..d39a0d1
--- /dev/null
+++ b/tests/test_exec_onepager.py
@@ -0,0 +1,176 @@
+"""
+TDD for R8 (executive one-pager) + R9 (credibility keystone to the front).
+
+The FIRST content page after the cover must be a self-contained executive
+one-pager carrying, in order:
+ 1. a reconciled headline verdict (the CAPPED OASIS status, not the raw mean);
+ 2. KPI cards with reference anchors + the alpha gradient position;
+ 3. an embedded Window-of-Viability chart (the marquee visual);
+ 4. top-3 risks in Evidence -> Implication form;
+ 5. prioritized next steps (roadmap, time-horizoned);
+then a clear "Detailed analysis follows" divider.
+
+R9: a "Why this applies to your organization" keystone paragraph must appear on
+the front matter (cover or first exec page), led by the organizational evidence.
+
+Honesty guardrail: the exec summary must NOT render a bare absolute-fail
+"Non-Viable" / "UNSUSTAINABLE" verdict (the gradient reframe already shipped).
+"""
+import io
+import json
+
+import numpy as np
+import pytest
+
+from src.ulanowicz_calculator import UlanowiczCalculator
+from src.oasis_calculator import OASISCalculator
+from src.publication_report import PublicationReportGenerator
+from src.pdf_generator import generate_pdf_report
+
+
+# Three contrasting sample orgs (one capped-status org, one balanced, one viable).
+SAMPLE_ORGS = [
+ 'data/synthetic_organizations/combined_flows/tech_company_combined_matrix.json',
+ 'data/synthetic_organizations/combined_flows/balanced_org_test.json',
+ 'data/ecosystem_samples/cone_spring_original.json',
+]
+
+# A capped org: SUSTAINABLE vetoes the raw HEALTHY mean down to WARNING.
+CAPPED_ORG = 'data/synthetic_organizations/combined_flows/tech_company_combined_matrix.json'
+
+
+def _load_sample(path):
+ d = json.load(open(path))
+ return (np.array(d['flows'], dtype=float),
+ d['nodes'],
+ d.get('organization', 'Org'))
+
+
+def _build(flow, nodes, org_name):
+ calc = UlanowiczCalculator(flow, nodes)
+ metrics = calc.get_extended_metrics()
+ assessments = calc.assess_regenerative_health()
+ profile = OASISCalculator(calc).get_oasis_profile()
+ rg = PublicationReportGenerator(
+ calculator=calc, metrics=metrics, assessments=assessments,
+ org_name=org_name, flow_matrix=flow, node_names=nodes,
+ oasis_profile=profile)
+ return rg, calc, metrics, profile
+
+
+def _pdf_text(pdf_bytes):
+ from pypdf import PdfReader
+ reader = PdfReader(io.BytesIO(pdf_bytes))
+ return [(page.extract_text() or "") for page in reader.pages]
+
+
+def _count_pdf_images(pdf_bytes):
+ return (pdf_bytes.count(b'/Subtype /Image')
+ + pdf_bytes.count(b'/Subtype/Image'))
+
+
+def _render(path):
+ flow, nodes, org = _load_sample(path)
+ rg, calc, metrics, profile = _build(flow, nodes, org)
+ pdf = generate_pdf_report(rg, calc, metrics, charts=None)
+ assert pdf and pdf[:4] == b'%PDF'
+ return pdf, profile
+
+
+# ---------------------------------------------------------------------------
+# The executive one-pager front section
+# ---------------------------------------------------------------------------
+
+def _front_text(pdf_bytes):
+ """Text of the cover + first exec content page (the one-pager lives here)."""
+ pages = _pdf_text(pdf_bytes)
+ # cover (page 0) + the exec one-pager page(s) that precede the divider
+ joined = "\n".join(pages[:3])
+ return joined
+
+
+def test_exec_onepager_has_reconciled_capped_verdict():
+ pdf, profile = _render(CAPPED_ORG)
+ front = _front_text(pdf)
+ # The capped status must be the stated verdict (not the raw mean's label).
+ assert profile['overall_status_capped'] is True
+ assert profile['overall_status'] in front # WARNING (capped)
+ assert 'capped' in front.lower()
+ # A dimension name that drove the cap must be named.
+ assert any(d.upper() in front.upper() for d in profile['capped_by'])
+
+
+def test_exec_onepager_has_alpha_gradient_position():
+ pdf, profile = _render(SAMPLE_ORGS[0])
+ front = _front_text(pdf)
+ # Gradient framing, never a bare pass/fail. One of the three positions shows.
+ assert any(p in front.lower()
+ for p in ('under-organized', 'over-organized', 'balanced'))
+ assert 'ฮฑ' in front or 'alpha' in front.lower()
+
+
+def test_exec_onepager_embeds_wov_image_on_front():
+ pdf, _ = _render(SAMPLE_ORGS[0])
+ pages = _pdf_text(pdf)
+ front = "\n".join(pages[:3])
+ # The marquee WoV visual caption appears in the exec summary.
+ assert 'Window of Viability' in front or 'Robustness' in front
+ # And there is at least one embedded raster image in the whole PDF.
+ assert _count_pdf_images(pdf) >= 1
+
+
+def test_exec_onepager_has_top_risk_line():
+ pdf, _ = _render(SAMPLE_ORGS[0])
+ front = _front_text(pdf)
+ assert 'Evidence' in front and 'Implication' in front
+
+
+def test_exec_onepager_has_next_step():
+ pdf, _ = _render(SAMPLE_ORGS[0])
+ front = _front_text(pdf)
+ # A time-horizoned next step (roadmap) appears in the one-pager.
+ assert ('Next Steps' in front or 'Action' in front)
+ assert any(h in front for h in
+ ('Immediate', 'Short-Term', 'Short-term', 'Medium-Term',
+ 'Medium-term', '0โ3', '0-3'))
+
+
+def test_exec_onepager_has_divider():
+ pdf, _ = _render(SAMPLE_ORGS[0])
+ front = _front_text(pdf)
+ assert 'Detailed analysis follows' in front
+
+
+def test_keystone_on_front_matter():
+ pdf, _ = _render(SAMPLE_ORGS[0])
+ front = _front_text(pdf)
+ assert 'Why this applies to your organization' in front
+ # Led by the organizational evidence (Fath 2019), with the indicative caveat.
+ assert 'Fath' in front
+ assert 'indicative' in front.lower() or 'directional' in front.lower()
+
+
+def test_no_bare_absolute_fail_verdict_in_exec():
+ pdf, _ = _render(CAPPED_ORG)
+ front = _front_text(pdf).lower()
+ # The reframe forbids bare absolute-fail organizational verdicts.
+ assert 'non-viable' not in front
+ assert 'unsustainable' not in front
+
+
+# ---------------------------------------------------------------------------
+# Robustness: degenerate-safe + all three sample orgs still build with images
+# ---------------------------------------------------------------------------
+
+def test_degenerate_network_onepager_safe():
+ flow = np.array([[0, 1, 0], [0, 0, 1], [1, 0, 0]], dtype=float)
+ nodes = ['X', 'Y', 'Z']
+ rg, calc, metrics, _ = _build(flow, nodes, 'Tiny Org')
+ pdf = generate_pdf_report(rg, calc, metrics, charts=None)
+ assert pdf and pdf[:4] == b'%PDF'
+
+
+@pytest.mark.parametrize('path', SAMPLE_ORGS)
+def test_three_sample_orgs_build_with_images(path):
+ pdf, _ = _render(path)
+ assert _count_pdf_images(pdf) >= 1
diff --git a/tests/test_full_profile.py b/tests/test_full_profile.py
new file mode 100644
index 0000000..beb0b15
--- /dev/null
+++ b/tests/test_full_profile.py
@@ -0,0 +1,194 @@
+"""
+Tests for the full-index precompute mechanism (Pass A).
+
+Covers:
+- precompute_full_profile returns all four families with representative keys.
+- get_full_profile: compute-once, read-thereafter (cache HIT does not recompute).
+- Version mismatch forces recompute (MISS -> restore with current version).
+- Degenerate graph returns a profile with per-family error markers, no crash.
+
+Uses a throwaway SQLite DB so the real DB is never touched.
+"""
+
+import numpy as np
+import pytest
+
+from src.database.db_manager import DatabaseManager
+from src.database.precompute_pipeline import PrecomputePipeline
+from src.database import full_profile as fp_mod
+from src.database.full_profile import (
+ precompute_full_profile,
+ FORMULA_VERSION,
+)
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+@pytest.fixture
+def flow_matrix():
+ """A small but non-degenerate directed flow network (5 nodes, cyclic)."""
+ return np.array([
+ [0, 10, 0, 0, 5],
+ [0, 0, 8, 2, 0],
+ [0, 0, 0, 7, 1],
+ [3, 0, 0, 0, 6],
+ [0, 4, 0, 0, 0],
+ ], dtype=float)
+
+
+@pytest.fixture
+def node_names():
+ return ['A', 'B', 'C', 'D', 'E']
+
+
+@pytest.fixture
+def pipeline(tmp_path):
+ """PrecomputePipeline wired to a throwaway SQLite DB."""
+ db_path = tmp_path / "test_networks.db"
+ db = DatabaseManager(db_path=str(db_path))
+ return PrecomputePipeline(db_manager=db)
+
+
+# ---------------------------------------------------------------------------
+# precompute_full_profile: family coverage
+# ---------------------------------------------------------------------------
+
+def test_full_profile_has_all_families(flow_matrix, node_names):
+ profile = precompute_full_profile(flow_matrix, node_names, org_name='Test Org')
+
+ assert profile['formula_version'] == FORMULA_VERSION
+ for family in ('core', 'oasis', 'network_analysis', 'intelligence', 'meta'):
+ assert family in profile, f"missing family: {family}"
+
+
+def test_core_family_keys(flow_matrix, node_names):
+ core = precompute_full_profile(flow_matrix, node_names)['core']
+ for key in ('ascendency', 'relative_ascendency', 'robustness',
+ 'number_of_roles', 'finn_cycling_index'):
+ assert key in core, f"core missing {key}"
+
+
+def test_oasis_family_keys(flow_matrix, node_names):
+ oasis = precompute_full_profile(flow_matrix, node_names)['oasis']
+ scores = oasis['dimension_scores']
+ for dim in ('open', 'autonomous', 'symbiotic', 'intelligent', 'sustainable'):
+ assert dim in scores, f"oasis dimension missing {dim}"
+ assert 'overall_status' in oasis
+ assert 'capped_by' in oasis
+
+
+def test_network_analysis_family_keys(flow_matrix, node_names):
+ na = precompute_full_profile(flow_matrix, node_names)['network_analysis']
+ assert 'centralities' in na
+ assert 'communities' in na
+
+
+def test_intelligence_family_keys(flow_matrix, node_names):
+ intel = precompute_full_profile(flow_matrix, node_names)['intelligence']
+ assert 'risk' in intel
+ assert 'benchmark' in intel
+
+
+def test_meta_family(flow_matrix, node_names):
+ meta = precompute_full_profile(flow_matrix, node_names, org_name='Acme')['meta']
+ assert meta['n_nodes'] == 5
+ assert meta['n_edges'] == int(np.sum(flow_matrix > 0))
+ assert meta['organization'] == 'Acme'
+
+
+# ---------------------------------------------------------------------------
+# get_full_profile: compute-once, read-thereafter
+# ---------------------------------------------------------------------------
+
+def test_get_full_profile_first_call_is_miss(pipeline, flow_matrix, node_names):
+ result = pipeline.get_full_profile(flow_matrix, node_names, org_name='Test Org')
+ assert result['cache_hit'] is False
+ assert result['profile']['formula_version'] == FORMULA_VERSION
+
+
+def test_cache_hit_does_not_recompute(pipeline, flow_matrix, node_names, monkeypatch):
+ """Second call on the same matrix is a HIT and must NOT recompute."""
+ # First call populates the cache.
+ first = pipeline.get_full_profile(flow_matrix, node_names, org_name='Test Org')
+ assert first['cache_hit'] is False
+
+ # Spy: fail loudly if precompute_full_profile is called again.
+ call_counter = {'n': 0}
+ real = fp_mod.precompute_full_profile
+
+ def spy(*args, **kwargs):
+ call_counter['n'] += 1
+ return real(*args, **kwargs)
+
+ monkeypatch.setattr(fp_mod, 'precompute_full_profile', spy)
+ # Also patch the reference imported into the pipeline module, if any.
+ import src.database.precompute_pipeline as pp_mod
+ if hasattr(pp_mod, 'precompute_full_profile'):
+ monkeypatch.setattr(pp_mod, 'precompute_full_profile', spy)
+
+ second = pipeline.get_full_profile(flow_matrix, node_names, org_name='Test Org')
+
+ assert second['cache_hit'] is True
+ assert call_counter['n'] == 0, "cache HIT must not recompute the profile"
+
+
+def test_version_mismatch_forces_recompute(pipeline, flow_matrix, node_names):
+ """A stored profile with an old formula_version is treated as a MISS."""
+ # Populate the cache.
+ first = pipeline.get_full_profile(flow_matrix, node_names, org_name='Test Org')
+ assert first['cache_hit'] is False
+
+ # Corrupt the stored version to simulate a pre-fix formula version.
+ network_hash = pipeline.db.compute_network_hash(flow_matrix, node_names)
+ network = pipeline.db.get_network_by_hash(network_hash)
+ pipeline.db.save_precomputed_metrics(
+ network_id=network['id'],
+ tier=3,
+ metrics={'formula_version': 'OLD-0000', 'core': {}, 'stale': True},
+ formula_version='OLD-0000',
+ )
+
+ # Now a read with the current version must MISS and recompute.
+ result = pipeline.get_full_profile(flow_matrix, node_names, org_name='Test Org')
+ assert result['cache_hit'] is False
+ assert result['profile']['formula_version'] == FORMULA_VERSION
+ assert 'stale' not in result['profile']
+
+ # And the store is refreshed to the current version.
+ stored = pipeline.db.get_precomputed_metrics(
+ network['id'], tier=3, required_version=FORMULA_VERSION
+ )
+ assert stored is not None
+ assert stored['formula_version'] == FORMULA_VERSION
+
+
+# ---------------------------------------------------------------------------
+# Degenerate graph: per-family error markers, no crash
+# ---------------------------------------------------------------------------
+
+def test_degenerate_graph_returns_profile_with_error_markers():
+ tiny = np.array([[0.0, 1.0], [0.0, 0.0]], dtype=float)
+ names = ['x', 'y']
+
+ # Must not raise.
+ profile = precompute_full_profile(tiny, names, org_name='Tiny')
+
+ assert profile['formula_version'] == FORMULA_VERSION
+ for family in ('core', 'oasis', 'network_analysis', 'intelligence', 'meta'):
+ assert family in profile
+
+ # If any family failed on the degenerate graph it must carry an error marker
+ # rather than having taken down the whole profile.
+ for family in ('core', 'oasis', 'network_analysis', 'intelligence'):
+ fam = profile[family]
+ if isinstance(fam, dict) and '_error' in fam:
+ assert isinstance(fam['_error'], str)
+
+
+def test_degenerate_graph_via_pipeline(pipeline):
+ tiny = np.array([[0.0, 1.0], [0.0, 0.0]], dtype=float)
+ result = pipeline.get_full_profile(tiny, ['x', 'y'], org_name='Tiny')
+ assert result['cache_hit'] is False
+ assert result['profile']['formula_version'] == FORMULA_VERSION
diff --git a/tests/test_gradient_reframe.py b/tests/test_gradient_reframe.py
new file mode 100644
index 0000000..2fb201b
--- /dev/null
+++ b/tests/test_gradient_reframe.py
@@ -0,0 +1,93 @@
+"""
+Tests for the gradient reframe of the OASIS viability verdict.
+
+The old binary "Viable / Non-Viable (PASS/FAIL)" language is replaced by a
+position-on-a-gradient + direction-of-travel, framed against the *indicative*
+ecological reference band [0.2, 0.6]. These tests pin the classifier API and
+assert that reframed verdict text carries the gradient position, the
+direction-of-travel, and the indicative caveat โ and never a bare absolute-fail
+string.
+
+No threshold constants or score formulas are changed by the reframe.
+"""
+import os
+import sys
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
+
+from src.report_intelligence import ( # noqa: E402
+ assess_alpha_position,
+ sustainable_verdict_narrative,
+ VIABILITY_LOWER,
+ VIABILITY_UPPER,
+ INDICATIVE_REFERENCE_CAVEAT,
+)
+
+
+# ---------------------------------------------------------------------------
+# Classifier: position + direction-of-travel
+# ---------------------------------------------------------------------------
+def test_under_organized():
+ r = assess_alpha_position(0.09)
+ assert r['position'] == 'under-organized'
+ assert r['direction_of_travel'] == 'increase structure / coordination'
+ # gradient value, signed below the lower edge
+ assert r['relative_distance'] < 0
+
+
+def test_balanced_mid():
+ r = assess_alpha_position(0.45)
+ assert r['position'] == 'balanced'
+ assert r['direction_of_travel'] == 'maintain balance'
+
+
+def test_over_organized():
+ r = assess_alpha_position(0.72)
+ assert r['position'] == 'over-organized'
+ assert r['direction_of_travel'] == 'increase redundancy / flexibility'
+ assert r['relative_distance'] > 0
+
+
+def test_boundary_lower_is_balanced():
+ r = assess_alpha_position(VIABILITY_LOWER) # exactly 0.2
+ assert r['position'] == 'balanced'
+ assert r['direction_of_travel'] == 'maintain balance'
+
+
+def test_boundary_upper_is_balanced():
+ r = assess_alpha_position(VIABILITY_UPPER) # exactly 0.6
+ assert r['position'] == 'balanced'
+ assert r['direction_of_travel'] == 'maintain balance'
+
+
+def test_descriptor_and_caveat_present():
+ r = assess_alpha_position(0.09)
+ assert isinstance(r['descriptor'], str) and r['descriptor']
+ assert 'indicative' in r['descriptor'].lower()
+ assert r['caveat'] == INDICATIVE_REFERENCE_CAVEAT
+ assert 'directional indicator' in r['caveat']
+ assert 'not a compliance threshold' in r['caveat']
+
+
+def test_constants_unchanged():
+ # Guard against accidental threshold drift.
+ assert VIABILITY_LOWER == 0.2
+ assert VIABILITY_UPPER == 0.6
+
+
+# ---------------------------------------------------------------------------
+# Reframed sustainability verdict text (oasis_calculator interpretations)
+# ---------------------------------------------------------------------------
+def test_reframed_low_alpha_verdict_uses_gradient_and_caveat():
+ # Low SUSTAINABLE score + low alpha: the reframed verdict must read as a
+ # gradient position + direction-of-travel, not a bare absolute fail.
+ text = sustainable_verdict_narrative(30, 0.09)
+ lowered = text.lower()
+ # gradient position + direction-of-travel present
+ assert 'under-organized' in lowered
+ assert 'increase structure' in lowered
+ # indicative-reference framing present
+ assert 'indicative' in lowered
+ # NO bare absolute pass/fail language
+ assert 'non-viable' not in lowered
+ assert 'unsustainable' not in lowered
diff --git a/tests/test_mutualism_fix.py b/tests/test_mutualism_fix.py
new file mode 100644
index 0000000..a38b80a
--- /dev/null
+++ b/tests/test_mutualism_fix.py
@@ -0,0 +1,254 @@
+"""
+Track-1 mutualism correction โ test-driven.
+
+Fath et al. (2019) Principle 8 defines ecological mutualism as an *integral*
+(direct + indirect) utility property, not the direct-only reciprocity the engine
+originally computed. The correct construction is Patten's integral utility matrix:
+
+ Direct utility: d_ij = (f_ij - f_ji) / T_i (T_i = throughflow of i)
+ Integral utility: U = (I - D)^(-1)
+ Network mutualism (benefit:cost) = sum(U>0) / |sum(U<0)| OVER OFF-DIAGONAL (i != j)
+
+The benefit:cost sums exclude the diagonal: network mutualism is a property of the
+OFF-DIAGONAL relational pairings (i != j). The diagonal of U is self-utility /
+return-flow (always >= 0) and is not a "relation"; including it inflates the
+numerator for every network. See ยงA5 of the ENA review.
+
+Patten's classic result: INDIRECT effects make relationships more mutualistic, so
+the integral b:c >= direct b:c on a network with a closed loop of indirect paths.
+Corollary: with NO indirect path (a 2-node network) integral b:c EQUALS direct b:c
+(no network-mutualism lift).
+
+See docs/business-revision/evidence/expert-ena-methods.md ยงA5 (A5-mutualism CONFIRM).
+"""
+import os
+import sys
+
+import numpy as np
+import pytest
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
+
+from ulanowicz_calculator import UlanowiczCalculator
+from oasis_calculator import OASISCalculator
+
+
+def _oasis(flow_matrix):
+ uc = UlanowiczCalculator(np.asarray(flow_matrix, dtype=float))
+ return OASISCalculator(uc)
+
+
+# ---------------------------------------------------------------------------
+# Hand-checkable U = (I - D)^-1 on a small directed network
+# ---------------------------------------------------------------------------
+
+def test_direct_utility_matrix_hand_checked():
+ """
+ 3-node ring A->B->C->A with equal flow f=1.
+ Throughflow T_i = out_i (for a balanced ring, in=out=1) so T_i = 1 for each.
+ d_ij = (f_ij - f_ji)/T_i.
+ Row A (T=1): to B f_AB=1, f_BA=0 -> +1 ; to C f_AC=0, f_CA=1 -> -1
+ Row B (T=1): to A -1 ; to C +1
+ Row C (T=1): to A +1 ; to B -1
+ """
+ F = np.array([
+ [0, 1, 0],
+ [0, 0, 1],
+ [1, 0, 0],
+ ], dtype=float)
+ oc = _oasis(F)
+ res = oc.calculate_mutualism_index()
+
+ D = np.asarray(res['direct_utility_matrix'])
+ expected_D = np.array([
+ [0.0, 1.0, -1.0],
+ [-1.0, 0.0, 1.0],
+ [1.0, -1.0, 0.0],
+ ])
+ assert np.allclose(D, expected_D), f"D mismatch:\n{D}"
+
+ # Integral utility U = (I - D)^-1, verified against numpy on the same D.
+ U = np.asarray(res['integral_utility_matrix'])
+ expected_U = np.linalg.inv(np.eye(3) - expected_D)
+ assert np.allclose(U, expected_U), f"U mismatch:\n{U}\nexpected:\n{expected_U}"
+
+
+def test_integral_utility_2node_hand_checked():
+ """
+ 2-node exploitative pair A->B (f=4), B->A (f=1).
+ T_A = 4, T_B = 1.
+ d_AB = (f_AB - f_BA)/T_A = (4-1)/4 = 0.75
+ d_BA = (f_BA - f_AB)/T_B = (1-4)/1 = -3.0
+ D = [[0, 0.75],[-3, 0]].
+ U = (I-D)^-1 computed analytically: det(I-D) = 1 - (0.75*3) ... via numpy.
+
+ A 2-node network has NO indirect path, so the OFF-DIAGONAL integral b:c must
+ EQUAL the direct b:c (no network-mutualism lift). Here both = 0.25 (the sole
+ off-diagonal positive is 0.75, the sole off-diagonal negative is -3.0 in BOTH
+ D and U, since the 2-node U merely rescales those off-diagonal signs). This
+ proves "no indirect path -> no lift" and that the diagonal is excluded (the
+ prior diagonal-inclusive value of ~0.917 was a self-utility artifact).
+ """
+ F = np.array([
+ [0, 4],
+ [1, 0],
+ ], dtype=float)
+ oc = _oasis(F)
+ res = oc.calculate_mutualism_index()
+
+ D = np.asarray(res['direct_utility_matrix'])
+ expected_D = np.array([[0.0, 0.75], [-3.0, 0.0]])
+ assert np.allclose(D, expected_D), f"D mismatch:\n{D}"
+
+ U = np.asarray(res['integral_utility_matrix'])
+ expected_U = np.linalg.inv(np.eye(2) - expected_D)
+ assert np.allclose(U, expected_U)
+
+ # No indirect path => integral b:c == direct b:c (off-diagonal aggregation).
+ assert res['direct_benefit_cost_ratio'] == pytest.approx(0.25)
+ assert res['integral_benefit_cost_ratio'] == pytest.approx(0.25)
+ assert res['integral_benefit_cost_ratio'] == pytest.approx(
+ res['direct_benefit_cost_ratio'])
+
+
+# ---------------------------------------------------------------------------
+# Patten's result: indirect effects increase mutualism (integral b:c >= direct b:c)
+# ---------------------------------------------------------------------------
+
+def test_indirect_effects_increase_mutualism():
+ """
+ A directed cycle of exploitative (one-way) links has ZERO direct pairwise
+ mutualism (no reciprocal pairs), but the closed loop of indirect effects makes
+ the network net-mutualistic under integral utility (Patten). With the OFF-
+ DIAGONAL aggregation the 4-ring gives a GENUINE indirect lift:
+ direct b:c = 1.0 (off-diagonal +/- of D balance on a symmetric ring)
+ integral b:c = 3.0 (indirect loop lifts the positive utility)
+ (The prior diagonal-inclusive integral value was 6.0, a self-utility artifact.)
+ """
+ # 4-node ring: pure one-way exploitation around the loop.
+ F = np.array([
+ [0, 5, 0, 0],
+ [0, 0, 5, 0],
+ [0, 0, 0, 5],
+ [5, 0, 0, 0],
+ ], dtype=float)
+ oc = _oasis(F)
+ res = oc.calculate_mutualism_index()
+
+ direct_bc = res['direct_benefit_cost_ratio']
+ integral_bc = res['integral_benefit_cost_ratio']
+
+ # Direct-only pairwise: no reciprocal (bidirectional) pairs -> mutualism is 0.
+ assert res['direct_mutualism'] == pytest.approx(0.0)
+ # Off-diagonal corrected b:c values.
+ assert direct_bc == pytest.approx(1.0)
+ assert integral_bc == pytest.approx(3.0)
+ # Patten: indirect effects make it strictly MORE mutualistic here.
+ assert integral_bc > direct_bc
+ assert res['fallback_direct_only'] is False
+
+
+def test_benefit_cost_excludes_diagonal():
+ """
+ Guard: the b:c aggregation must exclude U's diagonal (self-utility). The
+ diagonal of U is always >= 0, so including it would inflate the numerator.
+ We verify the reported integral b:c matches an off-diagonal recomputation of
+ the returned U, and does NOT match the diagonal-inclusive value.
+ """
+ F = np.array([
+ [0, 5, 0, 0],
+ [0, 0, 5, 0],
+ [0, 0, 0, 5],
+ [5, 0, 0, 0],
+ ], dtype=float)
+ oc = _oasis(F)
+ res = oc.calculate_mutualism_index()
+ U = np.asarray(res['integral_utility_matrix'])
+
+ off = U.copy()
+ np.fill_diagonal(off, 0.0)
+ off_bc = off[off > 0].sum() / abs(off[off < 0].sum())
+
+ incl_bc = U[U > 0].sum() / abs(U[U < 0].sum())
+
+ assert res['integral_benefit_cost_ratio'] == pytest.approx(off_bc)
+ assert res['integral_benefit_cost_ratio'] != pytest.approx(incl_bc)
+
+
+def test_singular_matrix_fallback():
+ """
+ If (I - D) is singular / non-invertible, the calculator must fall back to
+ direct-only reporting with a flag, and must not raise.
+ """
+ # An empty / disconnected network (all zero flows). Every throughflow is 0,
+ # D is all-zero, (I-D)=I is invertible, so construct a genuinely singular case
+ # by monkeypatching is not ideal; instead use a network that yields singular I-D.
+ # A perfectly balanced 2-cycle A<->B with equal flows gives d_AB=d_BA=0 (D=0),
+ # which is invertible; to force singularity we build I-D singular directly.
+ # Use a 2-node network whose D makes (I-D) singular:
+ # want det(I-D)=0. With D=[[0,a],[b,0]], det(I-D)=1-ab=0 -> ab=1.
+ # d_AB = (f_AB-f_BA)/T_A, d_BA=(f_BA-f_AB)/T_B. Pick flows so a*b=1.
+ # f_AB=2,f_BA=0 -> T_A=2 -> a=1 ; f: for b we need (f_BA-f_AB)/T_B=1 -> impossible sign.
+ # Simpler: assert the API exposes the fallback flag and handles a forced singular D.
+ F = np.array([
+ [0, 2],
+ [0, 0],
+ ], dtype=float)
+ oc = _oasis(F)
+ # Force a singular (I - D) by patching the direct-utility builder's output.
+ orig = oc._build_direct_utility_matrix
+ oc._build_direct_utility_matrix = lambda: np.array([[0.0, 1.0], [1.0, 0.0]]) # det(I-D)=1-1=0
+ try:
+ res = oc.calculate_mutualism_index()
+ finally:
+ oc._build_direct_utility_matrix = orig
+
+ assert res['fallback_direct_only'] is True
+ # On fallback, integral values fall back to the direct component (no crash).
+ assert 'direct_mutualism' in res
+ assert res['integral_utility_matrix'] is None
+
+
+def test_near_singular_matrix_fallback():
+ """
+ A merely-singular check (det < tiny) misses near-singular blow-ups: det ~ 1e-6
+ -> U entries ~ 1e6 -> b:c explodes -> integral_mutualism pins to 1.0. The guard
+ must use a CONDITION-NUMBER test and fall back to direct-only on ill-conditioning.
+
+ Build D so (I - D) is near-singular: D = [[0, 1],[1-1e-6, 0]] ->
+ det(I - D) = 1 - (1)(1-1e-6) = 1e-6, cond(I - D) ~ 4e6 (well above any real
+ network's cond, which is < ~10). The calculator must set fallback_direct_only.
+ """
+ F = np.array([
+ [0, 2],
+ [0, 0],
+ ], dtype=float)
+ oc = _oasis(F)
+ orig = oc._build_direct_utility_matrix
+ # near-singular (not exactly singular): det(I-D) = 1e-6, cond ~ 4e6
+ oc._build_direct_utility_matrix = lambda: np.array([[0.0, 1.0],
+ [1.0 - 1e-6, 0.0]])
+ try:
+ res = oc.calculate_mutualism_index()
+ finally:
+ oc._build_direct_utility_matrix = orig
+
+ assert res['fallback_direct_only'] is True
+ assert res['integral_utility_matrix'] is None
+ # integral b:c must NOT have exploded / pinned to 1.0 โ it falls back to direct.
+ assert res['integral_benefit_cost_ratio'] == pytest.approx(
+ res['direct_benefit_cost_ratio'])
+
+
+def test_backcompat_keys_present():
+ """The original public keys must survive for existing consumers."""
+ F = np.array([
+ [0, 3, 1],
+ [2, 0, 4],
+ [1, 5, 0],
+ ], dtype=float)
+ oc = _oasis(F)
+ res = oc.calculate_mutualism_index()
+ for k in ('mutual_pairs', 'one_way_pairs', 'mutualism_ratio',
+ 'weighted_mutualism', 'total_connected_pairs'):
+ assert k in res, f"back-compat key missing: {k}"
diff --git a/tests/test_network_fixes.py b/tests/test_network_fixes.py
new file mode 100644
index 0000000..efac6f2
--- /dev/null
+++ b/tests/test_network_fixes.py
@@ -0,0 +1,464 @@
+"""
+Track-1 Network-Science Formula Corrections โ Test Suite
+=========================================================
+
+Test-driven pins for the six canonical-reference-backed corrections (FIX A-F).
+Each fix has a failing-then-passing test that pins the corrected behavior.
+
+References:
+- Freeman (1979): directed degree centralization normalizer = (n-1)^2.
+- Brandes (2001): weighted betweenness/closeness treat weight as DISTANCE (invert flow -> 1/flow).
+- Fronczak et al. (2004): L_rand ~ ln(n)/ln, with = 2m/n.
+- Telford/Bassett et al. (2011): omega = L_rand/L - C/C_lattice (lattice clustering, not random).
+- Colizza et al. (2006): rich-club must be normalized against a degree-preserving randomization.
+- Flow-diversity utilization: numerator (nats) and denominator log-base must match.
+
+Flow networks are DIRECTED (nx.DiGraph). Metrics that legitimately require an undirected
+projection (small-world, rich-club) are projected explicitly.
+"""
+
+import os
+import sys
+
+import numpy as np
+import networkx as nx
+import pytest
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
+
+from ulanowicz_calculator import UlanowiczCalculator
+from network_analyzer import AdvancedNetworkAnalyzer
+import publication_report
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _directed_star_matrix(n):
+ """Hub (node 0) -> all other nodes. Directed out-star."""
+ m = np.zeros((n, n))
+ for j in range(1, n):
+ m[0, j] = 1.0
+ return m
+
+
+def _random_directed_matrix(n, density=0.4, seed=0):
+ rng = np.random.default_rng(seed)
+ m = np.zeros((n, n))
+ for i in range(n):
+ for j in range(n):
+ if i != j and rng.random() < density:
+ m[i, j] = rng.random() * 10 + 1
+ return m
+
+
+# ---------------------------------------------------------------------------
+# FIX A โ Freeman centralization directed normalizer (n-1)^2
+# ---------------------------------------------------------------------------
+
+class TestFixAFreemanCentralization:
+ """Directed degree centralization must use (n-1)^2, never exceed 1."""
+
+ def test_directed_star_out_centralization_near_one(self):
+ n = 6
+ calc = UlanowiczCalculator(_directed_star_matrix(n), use_vectorized=False)
+ topo = calc.calculate_network_topology_metrics()
+ # Out-star: one hub with out-degree n-1, rest 0.
+ # Sum(d*-d_i) = (n-1)^2, normalizer (n-1)^2 => exactly 1.0.
+ assert topo["out_degree_centralization"] == pytest.approx(1.0, abs=1e-9)
+ assert 0.0 <= topo["out_degree_centralization"] <= 1.0
+ # The averaged degree_centralization must also stay in-bounds.
+ assert 0.0 <= topo["degree_centralization"] <= 1.0
+
+ def test_random_directed_centralization_bounded(self):
+ for seed in range(6):
+ n = 8
+ calc = UlanowiczCalculator(
+ _random_directed_matrix(n, seed=seed), use_vectorized=False
+ )
+ topo = calc.calculate_network_topology_metrics()
+ for key in ("in_degree_centralization", "out_degree_centralization",
+ "degree_centralization"):
+ assert 0.0 <= topo[key] <= 1.0, (
+ f"{key}={topo[key]} out of [0,1] at seed {seed}"
+ )
+
+ def test_old_normalizer_would_exceed_one(self):
+ """Documents the pre-fix defect: (n-1)(n-2) gives >1 on the star."""
+ n = 6
+ sum_diff = (n - 1) ** 2 # out-star raw dispersion
+ old = sum_diff / ((n - 1) * (n - 2)) # buggy denominator
+ new = sum_diff / ((n - 1) ** 2) # correct denominator
+ assert old > 1.0
+ assert new == pytest.approx(1.0)
+
+
+# ---------------------------------------------------------------------------
+# FIX B โ Betweenness/closeness treat flow as strength (invert to distance)
+# ---------------------------------------------------------------------------
+
+class TestFixBBetweennessInversion:
+ """High-flow bridge endpoints must score HIGH betweenness (1/flow distance)."""
+
+ def _parallel_route_matrix(self):
+ # 5-node hand-checkable case with TWO parallel routes from node 0 to node 4:
+ # STRONG route through node 1: 0 == 1 == 4 (flow = 100 each tie)
+ # WEAK route through 2 and 3: 0 -- 2 -- 3 -- 4 (flow = 1 each tie)
+ #
+ # Correct behavior (Brandes 2001, weight = distance): invert to 1/flow so
+ # the strong route is SHORT (distance 0.01+0.01=0.02) and the weak route is
+ # LONG (1+1+1=3). Shortest paths take the strong route, so the strong-route
+ # hub (node 1) gets HIGH betweenness and the weak-route hubs (2,3) get 0.
+ #
+ # Buggy behavior (raw flow as distance): strong route sums to 200 (looks
+ # LONG) while the weak route sums to 3 (looks SHORT) -> paths take the weak
+ # route, so node 2/3 get high betweenness and node 1 gets ZERO. The fix
+ # flips this ranking.
+ n = 5
+ m = np.zeros((n, n))
+ strong, weak = 100.0, 1.0
+
+ def bi(a, b, w):
+ m[a, b] = w
+ m[b, a] = w
+
+ bi(0, 1, strong)
+ bi(1, 4, strong) # strong route hub = node 1
+ bi(0, 2, weak)
+ bi(2, 3, weak)
+ bi(3, 4, weak) # weak route hubs = nodes 2, 3
+ return m
+
+ def test_strong_route_hub_ranks_high_after_inversion(self):
+ m = self._parallel_route_matrix()
+ analyzer = AdvancedNetworkAnalyzer(m, [f"N{i}" for i in range(5)])
+ btw = analyzer.calculate_centralities()["betweenness"]
+ # The strong-tie hub (node 1) must outrank the weak-route hubs (2, 3),
+ # because the inverted-distance shortest path prefers the strong route.
+ assert btw[1] > btw[2], f"strong hub {btw[1]:.3f} !> weak hub {btw[2]:.3f}"
+ assert btw[1] > btw[3], f"strong hub {btw[1]:.3f} !> weak hub {btw[3]:.3f}"
+ # Hand-checkable exact values (nx normalized betweenness on this graph):
+ assert btw[1] == pytest.approx(0.5, abs=1e-9)
+ assert btw[2] == pytest.approx(0.0, abs=1e-9)
+
+ def test_inversion_changes_ranking_vs_raw_weight(self):
+ """Pins that the fix REVERSES the ranking vs the buggy raw-weight metric.
+
+ Raw flow-as-distance ranks the weak-route hub above the strong-route hub
+ (node2 > node1); the corrected 1/flow distance ranks them the other way
+ (node1 > node2). This is the exact bug the fix corrects."""
+ m = self._parallel_route_matrix()
+ G = nx.DiGraph()
+ n = m.shape[0]
+ for i in range(n):
+ for j in range(n):
+ if m[i, j] > 0:
+ G.add_edge(i, j, weight=m[i, j])
+
+ # Buggy metric: flow used directly as distance.
+ raw = nx.betweenness_centrality(G, weight="weight", normalized=True)
+ # Corrected metric: distance = 1/flow.
+ H = G.copy()
+ for u, v, d in H.edges(data=True):
+ d["distance"] = 1.0 / d["weight"]
+ inv = nx.betweenness_centrality(H, weight="distance", normalized=True)
+
+ # Buggy: weak-route hub (2) beats strong-route hub (1).
+ assert raw[2] > raw[1]
+ # Fixed: strong-route hub (1) beats weak-route hub (2) โ ranking flipped.
+ assert inv[1] > inv[2]
+
+ def test_analyzer_uses_inverted_distance_for_closeness(self):
+ """Closeness must also invert: strong ties => close, not far."""
+ m = self._parallel_route_matrix()
+ analyzer = AdvancedNetworkAnalyzer(m, [f"N{i}" for i in range(5)])
+ cent = analyzer.calculate_centralities()
+ assert "closeness" in cent
+ # all closeness values finite and non-negative
+ assert all(np.isfinite(v) and v >= 0 for v in cent["closeness"].values())
+
+ def test_eigenvector_pagerank_still_use_weight_as_strength(self):
+ """FIX B must NOT invert eigenvector/pagerank (weight = strength there)."""
+ m = self._parallel_route_matrix()
+ analyzer = AdvancedNetworkAnalyzer(m, [f"N{i}" for i in range(5)])
+ cent = analyzer.calculate_centralities()
+ # Sanity: these exist and are proper distributions/vectors.
+ assert abs(sum(cent["pagerank"].values()) - 1.0) < 1e-6
+ assert all(v >= 0 for v in cent["eigenvector"].values())
+
+
+# ---------------------------------------------------------------------------
+# FIX C โ Small-world random baseline mean degree = 2m/n
+# ---------------------------------------------------------------------------
+
+class TestFixCMeanDegree:
+ def test_mean_degree_complete_graph(self):
+ # Complete undirected K_n has = n-1 = 2m/n.
+ n = 6
+ Kn = nx.complete_graph(n)
+ m = Kn.number_of_edges()
+ expected = 2 * m / n
+ assert expected == pytest.approx(n - 1)
+ # exercise the analyzer's helper
+ adj = np.zeros((n, n))
+ for u, v in Kn.edges():
+ adj[u, v] = 1.0
+ adj[v, u] = 1.0
+ analyzer = AdvancedNetworkAnalyzer(adj, [f"N{i}" for i in range(n)])
+ Gu = analyzer.G.to_undirected()
+ assert analyzer._mean_degree(Gu) == pytest.approx(2 * Gu.number_of_edges() / n)
+
+ def test_mean_degree_ring_lattice(self):
+ # Ring lattice C_n (each node degree 2): = 2.
+ n = 8
+ ring = nx.cycle_graph(n)
+ adj = np.zeros((n, n))
+ for u, v in ring.edges():
+ adj[u, v] = 1.0
+ adj[v, u] = 1.0
+ analyzer = AdvancedNetworkAnalyzer(adj, [f"N{i}" for i in range(n)])
+ Gu = analyzer.G.to_undirected()
+ assert analyzer._mean_degree(Gu) == pytest.approx(2.0)
+
+ def test_small_world_metrics_finite(self):
+ m = _random_directed_matrix(10, density=0.5, seed=3)
+ analyzer = AdvancedNetworkAnalyzer(m, [f"N{i}" for i in range(10)])
+ sw = analyzer.calculate_small_world_metrics()
+ assert np.isfinite(sw["random_path_length"])
+ assert np.isfinite(sw["small_world_sigma"])
+ assert np.isfinite(sw["small_world_omega"])
+
+
+# ---------------------------------------------------------------------------
+# FIX D โ Small-world omega uses LATTICE clustering, bounded to [-1, 1]
+# ---------------------------------------------------------------------------
+
+class TestFixDOmegaLattice:
+ def test_omega_bounded_random(self):
+ for seed in range(5):
+ m = _random_directed_matrix(12, density=0.5, seed=seed)
+ analyzer = AdvancedNetworkAnalyzer(m, [f"N{i}" for i in range(12)])
+ sw = analyzer.calculate_small_world_metrics()
+ w = sw["small_world_omega"]
+ assert -1.0 - 1e-9 <= w <= 1.0 + 1e-9, f"omega={w} out of [-1,1] seed {seed}"
+
+ def test_ring_lattice_omega_negative(self):
+ # A ring lattice (Watts-Strogatz with p=0) is the lattice end => omega ~ -1.
+ n = 20
+ ws = nx.watts_strogatz_graph(n, 4, 0.0, seed=1)
+ adj = np.zeros((n, n))
+ for u, v in ws.edges():
+ adj[u, v] = 1.0
+ adj[v, u] = 1.0
+ analyzer = AdvancedNetworkAnalyzer(adj, [f"N{i}" for i in range(n)])
+ sw = analyzer.calculate_small_world_metrics()
+ assert sw["small_world_omega"] < 0.0
+
+ def test_lattice_clustering_helper(self):
+ # C_lattice approx = 3(k-2)/(4(k-1)) for ring lattice of mean degree k.
+ analyzer = AdvancedNetworkAnalyzer(
+ _directed_star_matrix(4), [f"N{i}" for i in range(4)]
+ )
+ k = 4
+ assert analyzer._lattice_clustering(k) == pytest.approx(3 * (k - 2) / (4 * (k - 1)))
+ # guard small k: no crash / division by zero
+ assert 0.0 <= analyzer._lattice_clustering(1) <= 1.0
+ assert 0.0 <= analyzer._lattice_clustering(2) <= 1.0
+
+
+# ---------------------------------------------------------------------------
+# FIX E โ Rich-club normalized (Colizza 2006) with small-graph guard
+# ---------------------------------------------------------------------------
+
+class TestFixERichClub:
+ def test_rich_core_normalized_above_one(self):
+ # Build a graph with a GENUINE rich core: a moderate-degree random
+ # background periphery plus a forced clique among 6 hub nodes and extra
+ # hub->periphery edges to lift core degrees well above the periphery.
+ # A degree-preserving randomization will NOT reproduce the extra core
+ # interconnection, so normalized phi(k) > 1 at the high-k (core) cutoff.
+ # (Colizza et al. 2006: phi_norm > 1 signals a real rich-club effect.)
+ rng = np.random.default_rng(3)
+ G = nx.gnm_random_graph(60, 90, seed=5) # moderate-degree periphery
+ core = list(range(6))
+ for i in core: # force a clique on the core
+ for j in core:
+ if i < j:
+ G.add_edge(i, j)
+ for c in core: # boost core degree above periphery
+ for _ in range(8):
+ G.add_edge(c, int(rng.integers(6, 60)))
+
+ adj = np.zeros((len(G), len(G)))
+ for u, v in G.edges():
+ adj[u, v] = 1.0
+ adj[v, u] = 1.0
+ analyzer = AdvancedNetworkAnalyzer(adj, [f"N{i}" for i in range(len(G))])
+ rc = analyzer.calculate_rich_club_coefficient()
+ spectrum = rc["full_spectrum"]
+ assert isinstance(spectrum, dict) and len(spectrum) > 0
+ # At a high-k cutoff (core members, k >= 10) normalized phi should exceed 1.
+ high_k_vals = [v for k, v in spectrum.items() if k >= 10 and v is not None]
+ assert any(v > 1.0 for v in high_k_vals), (
+ f"expected a rich-club signal (phi>1) in {spectrum}"
+ )
+
+ def test_small_graph_returns_sentinel_not_crash(self):
+ # Tiny graph: normalized rich-club randomization is not meaningful; must
+ # not crash and must return the 'insufficient' sentinel.
+ m = _directed_star_matrix(3)
+ analyzer = AdvancedNetworkAnalyzer(m, [f"N{i}" for i in range(3)])
+ rc = analyzer.calculate_rich_club_coefficient()
+ assert rc["rich_club_coefficient"] == "insufficient" or rc["full_spectrum"] in ({}, None)
+ # no exception is the key assertion
+
+ def test_random_graph_phi_near_one(self):
+ # An Erdos-Renyi random graph has no rich-club: normalized phi ~ 1.
+ G = nx.gnp_random_graph(40, 0.25, seed=7)
+ adj = np.zeros((40, 40))
+ for u, v in G.edges():
+ adj[u, v] = 1.0
+ adj[v, u] = 1.0
+ analyzer = AdvancedNetworkAnalyzer(adj, [f"N{i}" for i in range(40)])
+ rc = analyzer.calculate_rich_club_coefficient()
+ spectrum = rc["full_spectrum"]
+ if isinstance(spectrum, dict) and spectrum:
+ vals = [v for v in spectrum.values() if v is not None]
+ if vals:
+ # Should hover around 1 (allow generous tolerance for finite size).
+ assert min(vals) < 2.0
+
+
+# ---------------------------------------------------------------------------
+# FIX F โ Flow-diversity utilization log base match (nats/nats)
+# ---------------------------------------------------------------------------
+
+class TestFixFUtilizationBase:
+ def test_uniform_flow_utilization_near_100(self):
+ # Uniform flow across all n^2 cells => flow diversity = ln(n^2) (nats),
+ # so utilization = fd / ln(n^2) * 100 = 100%.
+ n = 5
+ fd = np.log(n ** 2) # max diversity in nats
+ util = publication_report.flow_diversity_utilization(fd, n)
+ assert util == pytest.approx(100.0, abs=1e-6)
+
+ def test_old_base2_understated(self):
+ # Pre-fix used log2(n^2) in the denominator against a nats numerator,
+ # understating by factor ln2 (~0.693) => ~69.3% for the uniform case.
+ n = 5
+ fd = np.log(n ** 2)
+ old = fd / np.log2(n ** 2) * 100
+ assert old == pytest.approx(69.3147, abs=1e-2)
+ # the fix must be higher than the old understated value
+ assert publication_report.flow_diversity_utilization(fd, n) > old
+
+ def test_guard_tiny_graph(self):
+ # n=1 => log(1)=0 denominator; must not divide by zero.
+ assert publication_report.flow_diversity_utilization(0.0, 1) == 0.0
+
+
+# ---------------------------------------------------------------------------
+# FIX D (follow-up) โ small-world sigma/omega triple must be base-consistent:
+# UNWEIGHTED clustering to match the UNWEIGHTED C_lattice and hop-count L.
+# ---------------------------------------------------------------------------
+
+def _weighted_ring_lattice_matrix(n=20, k=4, weight=5.0, seed=1):
+ """Weighted ring lattice (WS with p=0), every tie carries the same weight."""
+ ws = nx.watts_strogatz_graph(n, k, 0.0, seed=seed)
+ m = np.zeros((n, n))
+ for u, v in ws.edges():
+ m[u, v] = weight
+ m[v, u] = weight
+ return m
+
+
+class TestFixDClusteringConsistency:
+ """The C used in the sigma/omega triple must be the UNWEIGHTED topological
+ clustering (Onnela weighted clustering deflates C and biases omega toward
+ 'random' when mixed with an unweighted C_lattice and hop-count L)."""
+
+ def test_triple_uses_unweighted_clustering(self):
+ # On a weighted ring lattice the Onnela weighted clustering is materially
+ # BELOW the unweighted topological clustering. The small-world triple must
+ # report the UNWEIGHTED value.
+ m = _weighted_ring_lattice_matrix(n=20, k=4, weight=5.0)
+ analyzer = AdvancedNetworkAnalyzer(m, [f"N{i}" for i in range(20)])
+ sw = analyzer.calculate_small_world_metrics()
+
+ Gu = analyzer.G.to_undirected()
+ unweighted_C = nx.average_clustering(Gu) # topological
+ weighted_C = nx.average_clustering(Gu, weight="weight") # Onnela
+
+ # The C fed to the triple must equal the UNWEIGHTED clustering ...
+ assert sw["clustering_coefficient"] == pytest.approx(unweighted_C, abs=1e-9)
+ # ... and the Onnela weighted value is preserved separately.
+ assert sw["weighted_clustering_coefficient"] == pytest.approx(weighted_C, abs=1e-9)
+
+ def test_weighted_lattice_omega_negative(self):
+ # A (weighted) ring lattice is the lattice end -> omega must stay negative.
+ m = _weighted_ring_lattice_matrix(n=24, k=4, weight=7.0)
+ analyzer = AdvancedNetworkAnalyzer(m, [f"N{i}" for i in range(24)])
+ sw = analyzer.calculate_small_world_metrics()
+ assert sw["small_world_omega"] < 0.0
+
+ def test_uniform_weight_does_not_change_topological_C(self):
+ # With uniform weights, Onnela weighted clustering != unweighted in general
+ # (weighted uses geometric-mean triangle intensity), so the base choice
+ # genuinely matters. Assert the triple's C matches the unweighted value
+ # even when all weights are identical.
+ n = 16
+ ws = nx.watts_strogatz_graph(n, 4, 0.0, seed=3)
+ m = np.zeros((n, n))
+ for u, v in ws.edges():
+ m[u, v] = 3.0
+ m[v, u] = 3.0
+ analyzer = AdvancedNetworkAnalyzer(m, [f"N{i}" for i in range(n)])
+ sw = analyzer.calculate_small_world_metrics()
+ Gu = analyzer.G.to_undirected()
+ assert sw["clustering_coefficient"] == pytest.approx(nx.average_clustering(Gu), abs=1e-9)
+
+
+# ---------------------------------------------------------------------------
+# Katz centrality (validation N7) โ fixed alpha=0.1 overflows on dense/
+# strong-flow graphs. Alpha must be adaptive: alpha < 1/lambda_max.
+# ---------------------------------------------------------------------------
+
+def _dense_strong_flow_matrix(n=8, weight=100.0):
+ """Dense graph with large edge weights -> large lambda_max -> fixed
+ alpha=0.1 diverges/overflows in Katz iteration."""
+ m = np.zeros((n, n))
+ for i in range(n):
+ for j in range(n):
+ if i != j:
+ m[i, j] = weight
+ return m
+
+
+class TestKatzAdaptiveAlpha:
+ def test_katz_no_overflow_finite(self, recwarn):
+ import warnings
+ m = _dense_strong_flow_matrix(n=8, weight=100.0)
+ analyzer = AdvancedNetworkAnalyzer(m, [f"N{i}" for i in range(8)])
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", RuntimeWarning) # overflow => error
+ cent = analyzer.calculate_centralities()
+ katz = cent["katz"]
+ assert len(katz) == 8
+ assert all(np.isfinite(v) for v in katz.values())
+
+ def test_alpha_below_inverse_lambda_max(self):
+ # The adaptive alpha must satisfy Katz's convergence bound alpha < 1/lambda_max.
+ m = _dense_strong_flow_matrix(n=8, weight=100.0)
+ analyzer = AdvancedNetworkAnalyzer(m, [f"N{i}" for i in range(8)])
+ alpha = analyzer._katz_alpha(analyzer.G)
+ A = nx.to_numpy_array(analyzer.G, weight="weight")
+ lambda_max = max(abs(np.linalg.eigvals(A)))
+ assert 0 < alpha < 1.0 / lambda_max
+
+ def test_katz_still_works_on_sparse_graph(self):
+ # Sanity: adaptive alpha must not break the normal sparse case.
+ m = _random_directed_matrix(10, density=0.3, seed=5)
+ analyzer = AdvancedNetworkAnalyzer(m, [f"N{i}" for i in range(10)])
+ cent = analyzer.calculate_centralities()
+ assert all(np.isfinite(v) for v in cent["katz"].values())
diff --git a/tests/test_network_ingestion.py b/tests/test_network_ingestion.py
new file mode 100644
index 0000000..6ccbaad
--- /dev/null
+++ b/tests/test_network_ingestion.py
@@ -0,0 +1,172 @@
+import numpy as np
+import pytest
+
+from src import network_ingestion as ni
+
+
+# ---- matrix format ----
+
+def test_parse_matrix_basic():
+ csv = (",A,B,C\n"
+ "A,0,5,0\n"
+ "B,0,0,3\n"
+ "C,2,0,0\n")
+ res = ni.parse_network_csv(csv)
+ assert res.fmt == 'matrix'
+ assert res.node_names == ['A', 'B', 'C']
+ assert res.flow_matrix.shape == (3, 3)
+ assert res.flow_matrix[0, 1] == 5
+ assert res.flow_matrix[2, 0] == 2
+
+
+def test_parse_matrix_non_square_raises():
+ csv = ",A,B,C\nA,0,5,0\nB,0,0,3\n"
+ with pytest.raises(ni.NetworkIngestionError):
+ ni.parse_network_csv(csv)
+
+
+def test_parse_matrix_non_numeric_raises():
+ csv = ",A,B\nA,0,x\nB,1,0\n"
+ with pytest.raises(ni.NetworkIngestionError):
+ ni.parse_network_csv(csv)
+
+
+def test_parse_matrix_negative_raises():
+ csv = ",A,B\nA,0,-5\nB,1,0\n"
+ with pytest.raises(ni.NetworkIngestionError):
+ ni.parse_network_csv(csv)
+
+
+# ---- edge list format ----
+
+def test_parse_edge_list_with_headers():
+ csv = ("source,target,weight\n"
+ "A,B,5\n"
+ "B,C,3\n"
+ "C,A,2\n")
+ res = ni.parse_network_csv(csv)
+ assert res.fmt == 'edgelist'
+ assert res.node_names == ['A', 'B', 'C']
+ assert res.flow_matrix[0, 1] == 5
+ assert res.flow_matrix[1, 2] == 3
+ assert res.flow_matrix[2, 0] == 2
+
+
+def test_parse_edge_list_synonym_headers():
+ csv = ("from,to,count\n"
+ "Sales,IT,10\n"
+ "IT,Sales,4\n")
+ res = ni.parse_network_csv(csv)
+ assert res.fmt == 'edgelist'
+ assert set(res.node_names) == {'Sales', 'IT'}
+
+
+def test_parse_edge_list_aggregates_duplicates():
+ csv = ("source,target,weight\n"
+ "A,B,5\n"
+ "A,B,3\n"
+ "B,A,1\n")
+ res = ni.parse_network_csv(csv)
+ i = res.node_names.index('A')
+ j = res.node_names.index('B')
+ assert res.flow_matrix[i, j] == 8
+
+
+def test_parse_edge_list_no_weight_defaults_to_one():
+ csv = ("source,target\n"
+ "A,B\n"
+ "A,B\n"
+ "B,A\n")
+ res = ni.parse_network_csv(csv)
+ i = res.node_names.index('A')
+ j = res.node_names.index('B')
+ assert res.flow_matrix[i, j] == 2
+ assert any('counted as 1' in w for w in res.warnings)
+
+
+def test_parse_edge_list_heuristic_no_known_headers():
+ # No recognized headers, but two label columns + a numeric column.
+ csv = ("dept_a,dept_b,n\n"
+ "X,Y,7\n"
+ "Y,X,2\n")
+ res = ni.parse_network_csv(csv)
+ assert res.fmt == 'edgelist'
+ assert set(res.node_names) == {'X', 'Y'}
+
+
+# ---- validation warnings ----
+
+def test_isolated_node_warning():
+ # D appears as a column but receives/sends nothing in edge form -> use matrix
+ csv = (",A,B,D\n"
+ "A,0,5,0\n"
+ "B,3,0,0\n"
+ "D,0,0,0\n")
+ res = ni.parse_network_csv(csv)
+ assert any('isolated' in w.lower() for w in res.warnings)
+
+
+def test_self_loop_warning():
+ csv = (",A,B\n"
+ "A,2,5\n"
+ "B,3,0\n")
+ res = ni.parse_network_csv(csv)
+ assert any('self-loop' in w.lower() for w in res.warnings)
+
+
+def test_zero_total_flow_raises():
+ csv = ",A,B\nA,0,0\nB,0,0\n"
+ with pytest.raises(ni.NetworkIngestionError):
+ ni.parse_network_csv(csv)
+
+
+def test_empty_raises():
+ with pytest.raises(ni.NetworkIngestionError):
+ ni.parse_network_csv("\n")
+
+
+# ---- templates ----
+
+def test_templates_roundtrip():
+ m = ni.parse_network_csv(ni.matrix_template_csv())
+ assert m.fmt == 'matrix' and len(m.node_names) == 4
+ e = ni.parse_network_csv(ni.edgelist_template_csv())
+ assert e.fmt == 'edgelist' and len(e.node_names) == 4
+
+
+# ---- connector primitive: build_flow_matrix_from_edges ----
+
+def test_build_from_edges_with_weights():
+ edges = [('A', 'B', 5), ('B', 'C', 3), ('A', 'B', 2)]
+ res = ni.build_flow_matrix_from_edges(edges)
+ assert res.fmt == 'edgelist'
+ i, j = res.node_names.index('A'), res.node_names.index('B')
+ assert res.flow_matrix[i, j] == 7
+
+
+def test_build_from_edges_default_weight():
+ edges = [('A', 'B'), ('A', 'B'), ('B', 'A')]
+ res = ni.build_flow_matrix_from_edges(edges)
+ i, j = res.node_names.index('A'), res.node_names.index('B')
+ assert res.flow_matrix[i, j] == 2
+
+
+def test_build_from_edges_single_node_raises():
+ with pytest.raises(ni.NetworkIngestionError):
+ ni.build_flow_matrix_from_edges([('A', 'A', 5)])
+
+
+def test_build_from_edges_passes_connector_warnings():
+ res = ni.build_flow_matrix_from_edges(
+ [('A', 'B', 1)], extra_warnings=['Sampled last 30 days only.'])
+ assert any('30 days' in w for w in res.warnings)
+
+
+# ---- end-to-end into the engine ----
+
+def test_ingested_matrix_feeds_calculator():
+ from src.ulanowicz_calculator import UlanowiczCalculator
+ res = ni.parse_network_csv(ni.edgelist_template_csv())
+ calc = UlanowiczCalculator(res.flow_matrix, res.node_names)
+ metrics = calc.get_extended_metrics()
+ assert metrics['total_system_throughput'] > 0
diff --git a/tests/test_pdf_generator_detailed.py b/tests/test_pdf_generator_detailed.py
new file mode 100644
index 0000000..0c24034
--- /dev/null
+++ b/tests/test_pdf_generator_detailed.py
@@ -0,0 +1,58 @@
+"""
+Integration test: the app's actual PDF path (src/pdf_generator.generate_pdf_report,
+reportlab) must include the detailed ecosystemic sections.
+"""
+import numpy as np
+import pytest
+
+from src.ulanowicz_calculator import UlanowiczCalculator
+from src.publication_report import PublicationReportGenerator
+from src.pdf_generator import generate_pdf_report
+
+
+def _render_pdf_bytes():
+ flow = np.array([
+ [0, 10, 0, 0, 5],
+ [0, 0, 8, 2, 0],
+ [0, 0, 0, 7, 1],
+ [3, 0, 0, 0, 6],
+ [0, 4, 0, 0, 0],
+ ], dtype=float)
+ nodes = ['A', 'B', 'C', 'D', 'E']
+ calc = UlanowiczCalculator(flow, nodes)
+ metrics = calc.get_extended_metrics()
+ assessments = calc.assess_regenerative_health()
+ rg = PublicationReportGenerator(
+ calculator=calc, metrics=metrics, assessments=assessments,
+ org_name='Test Org', flow_matrix=flow, node_names=nodes)
+ return generate_pdf_report(rg, calc, metrics, charts=None)
+
+
+def _pdf_text(pdf_bytes):
+ import io
+ from pypdf import PdfReader
+ reader = PdfReader(io.BytesIO(pdf_bytes))
+ return "\n".join((page.extract_text() or "") for page in reader.pages)
+
+
+def test_app_pdf_renders():
+ pdf = _render_pdf_bytes()
+ assert pdf is not None
+ assert pdf[:4] == b'%PDF'
+
+
+def test_app_pdf_contains_detailed_sections():
+ text = _pdf_text(_render_pdf_bytes())
+ assert 'Benchmarking' in text
+ assert 'Risk' in text and 'Resilience' in text
+ assert 'Action Roadmap' in text
+ assert 'ESG Framework Mapping' in text
+
+
+def test_app_pdf_sections_sequential():
+ text = _pdf_text(_render_pdf_bytes())
+ # Renumbered sections must be present and ordered
+ for marker in ['5. Benchmarking', '6. Risk', '7. Prioritized Action Roadmap',
+ '8. ESG Framework Mapping', '9. Discussion',
+ '10. Conclusions']:
+ assert marker in text, f"missing section marker: {marker}"
diff --git a/tests/test_pdf_images.py b/tests/test_pdf_images.py
new file mode 100644
index 0000000..5a3c0a7
--- /dev/null
+++ b/tests/test_pdf_images.py
@@ -0,0 +1,102 @@
+"""
+TDD: the reportlab PDF path must EMBED visualizations (not just tables/text).
+
+Before this work `pdfimages -list` reported ZERO images in the generated PDF.
+These tests assert the report now embeds real raster charts, including the
+Window-of-Viability / robustness curve, and that generation is robust to
+degenerate (tiny) networks.
+"""
+import io
+import json
+import shutil
+import subprocess
+
+import numpy as np
+import pytest
+
+from src.ulanowicz_calculator import UlanowiczCalculator
+from src.publication_report import PublicationReportGenerator
+from src.pdf_generator import generate_pdf_report
+
+
+def _build_report_generator(flow, nodes, org_name='Test Org'):
+ calc = UlanowiczCalculator(flow, nodes)
+ metrics = calc.get_extended_metrics()
+ assessments = calc.assess_regenerative_health()
+ rg = PublicationReportGenerator(
+ calculator=calc, metrics=metrics, assessments=assessments,
+ org_name=org_name, flow_matrix=flow, node_names=nodes)
+ return rg, calc, metrics
+
+
+def _load_sample(path):
+ d = json.load(open(path))
+ return np.array(d['flows'], dtype=float), d['nodes'], d.get('organization', 'Org')
+
+
+def _count_pdf_images(pdf_bytes):
+ """Count embedded raster images. Prefer pdfimages; fall back to raw parse."""
+ if shutil.which('pdfimages'):
+ import tempfile, os
+ with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tf:
+ tf.write(pdf_bytes)
+ tmp = tf.name
+ try:
+ out = subprocess.run(['pdfimages', '-list', tmp],
+ capture_output=True, text=True)
+ lines = [l for l in out.stdout.splitlines()
+ if l.strip() and l.split()[0].isdigit()]
+ return len(lines)
+ finally:
+ os.unlink(tmp)
+ # Fallback: count image XObjects in the raw PDF stream
+ return pdf_bytes.count(b'/Subtype /Image') + pdf_bytes.count(b'/Subtype/Image')
+
+
+def test_pdf_embeds_at_least_three_images():
+ """The Cone Spring sample must yield a PDF with >= 3 embedded images."""
+ flow, nodes, org = _load_sample(
+ 'data/ecosystem_samples/cone_spring_original.json')
+ rg, calc, metrics = _build_report_generator(flow, nodes, org)
+ pdf = generate_pdf_report(rg, calc, metrics, charts=None)
+ assert pdf and pdf[:4] == b'%PDF'
+ n = _count_pdf_images(pdf)
+ assert n >= 3, f"expected >= 3 embedded images, got {n}"
+
+
+def test_window_of_viability_curve_present():
+ """The sustainability section must render the WoV/robustness curve image."""
+ flow, nodes, org = _load_sample(
+ 'data/ecosystem_samples/cone_spring_original.json')
+ rg, calc, metrics = _build_report_generator(flow, nodes, org)
+ pdf = generate_pdf_report(rg, calc, metrics, charts=None)
+ text = _pdf_text(pdf)
+ # Caption of the WoV figure is emitted next to the embedded Image flowable.
+ assert 'Window of Viability' in text or 'Robustness Curve' in text
+
+
+def test_degenerate_network_does_not_crash():
+ """A 3-node degenerate network must still produce a PDF (charts guarded)."""
+ flow = np.array([
+ [0, 1, 0],
+ [0, 0, 1],
+ [1, 0, 0],
+ ], dtype=float)
+ nodes = ['X', 'Y', 'Z']
+ rg, calc, metrics = _build_report_generator(flow, nodes, 'Tiny Org')
+ pdf = generate_pdf_report(rg, calc, metrics, charts=None)
+ assert pdf and pdf[:4] == b'%PDF'
+
+
+def test_two_node_network_does_not_crash():
+ flow = np.array([[0, 1], [1, 0]], dtype=float)
+ nodes = ['A', 'B']
+ rg, calc, metrics = _build_report_generator(flow, nodes, 'Two Node')
+ pdf = generate_pdf_report(rg, calc, metrics, charts=None)
+ assert pdf and pdf[:4] == b'%PDF'
+
+
+def _pdf_text(pdf_bytes):
+ from pypdf import PdfReader
+ reader = PdfReader(io.BytesIO(pdf_bytes))
+ return "\n".join((page.extract_text() or "") for page in reader.pages)
diff --git a/tests/test_peer_cohort.py b/tests/test_peer_cohort.py
new file mode 100644
index 0000000..3a8700a
--- /dev/null
+++ b/tests/test_peer_cohort.py
@@ -0,0 +1,270 @@
+"""
+Tests for the PEER-COHORT benchmarking scaffold (percentile-vs-peers).
+
+HONESTY CONTRACT (mirrors the product requirement):
+- No peer number is ever fabricated.
+- When the size/sector-matched cohort has fewer than MIN_COHORT_SIZE members
+ the mechanism MUST return an ``insufficient_cohort`` status and NO percentile.
+- ``insufficient_cohort`` is the expected DEFAULT state today (the real store
+ does not yet hold >=10 sector-matched peers).
+
+All DB work happens in a throwaway temp SQLite file; the real DB is untouched.
+"""
+
+import numpy as np
+import pytest
+
+from src.database.db_manager import DatabaseManager
+from src.database.precompute_pipeline import PrecomputePipeline, FULL_PROFILE_TIER
+from src.database import peer_cohort as pc
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+@pytest.fixture
+def db(tmp_path):
+ """A throwaway DatabaseManager backed by a temp SQLite file."""
+ return DatabaseManager(db_path=str(tmp_path / "cohort_test.db"))
+
+
+def _seed_member(db, name, node_count, alpha, sector='tech',
+ robustness=0.3, overall=60.0):
+ """Persist one synthetic cohort member (network + tier-3 profile blob).
+
+ Uses a *fabricated* profile blob purely to exercise the query/percentile
+ plumbing quickly; it does NOT run the real calculators. The alpha value is
+ the number under test. Members default to a sector tag because only tagged
+ (deliberately registered) networks are eligible peers.
+ """
+ net_hash = f"hash_{name}"
+ net_id = db.save_network(
+ name=name, source_file="", node_count=node_count,
+ edge_count=node_count, network_hash=net_hash, sector=sector,
+ )
+ blob = {
+ 'core': {'relative_ascendency': alpha, 'robustness': robustness},
+ 'oasis': {'overall_score': overall,
+ 'dimension_scores': {'open': overall}},
+ }
+ db.save_precomputed_metrics(net_id, FULL_PROFILE_TIER, blob)
+ return net_id
+
+
+# ---------------------------------------------------------------------------
+# 1. Percentile computation
+# ---------------------------------------------------------------------------
+
+def test_percentile_middle_of_five_is_about_50():
+ # value equal to the 3rd of 5 sorted peers -> median -> ~50th percentile
+ cohort = [0.10, 0.20, 0.30, 0.40, 0.50]
+ res = pc.compute_peer_percentile(0.30, cohort)
+ assert res['n'] == 5
+ assert res['percentile'] == pytest.approx(50.0, abs=1e-6)
+ assert res['median'] == pytest.approx(0.30)
+
+
+def test_percentile_top_and_bottom():
+ cohort = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
+ top = pc.compute_peer_percentile(100, cohort)
+ bottom = pc.compute_peer_percentile(-100, cohort)
+ assert top['percentile'] == pytest.approx(100.0)
+ assert bottom['percentile'] == pytest.approx(0.0)
+
+
+def test_percentile_reports_quartiles():
+ cohort = [10, 20, 30, 40, 50]
+ res = pc.compute_peer_percentile(35, cohort)
+ assert res['q1'] == pytest.approx(20.0)
+ assert res['median'] == pytest.approx(30.0)
+ assert res['q3'] == pytest.approx(40.0)
+
+
+# ---------------------------------------------------------------------------
+# 2. Size-bucket derivation (boundary correctness)
+# ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize("n,expected", [
+ (1, 'micro'), (9, 'micro'),
+ (10, 'small'), (49, 'small'),
+ (50, 'mid'), (249, 'mid'),
+ (250, 'large'), (10000, 'large'),
+])
+def test_size_bucket_boundaries(n, expected):
+ assert pc.size_bucket_from_node_count(n) == expected
+
+
+# ---------------------------------------------------------------------------
+# 3. Insufficient cohort (CRITICAL honesty guarantee)
+# ---------------------------------------------------------------------------
+
+def test_insufficient_cohort_returns_status_not_percentile():
+ cohort = [0.3, 0.4, 0.5] # only 3 peers, below MIN_COHORT_SIZE
+ res = pc.peer_benchmark(0.35, cohort)
+ assert res['status'] == 'insufficient_cohort'
+ assert res['n'] == 3
+ assert res['min'] == pc.MIN_COHORT_SIZE
+ assert 'percentile' not in res # NO fabricated number
+
+
+def test_empty_cohort_is_insufficient():
+ res = pc.peer_benchmark(0.42, [])
+ assert res['status'] == 'insufficient_cohort'
+ assert res['n'] == 0
+ assert 'percentile' not in res
+
+
+def test_default_state_of_fresh_store_is_insufficient(db):
+ """The honest DEFAULT: a store with no peers cannot benchmark alpha."""
+ res = pc.peer_alpha_benchmark(db, alpha=0.4, node_count=30)
+ assert res['status'] == 'insufficient_cohort'
+ assert 'percentile' not in res
+
+
+def test_untagged_networks_are_not_counted_as_peers(db):
+ """HONESTY GUARD: untagged records (ecological samples / synthetic fixtures)
+ must NEVER be counted as peer organizations, even if >=10 exist and match
+ the size bucket. This is the safeguard that keeps the real store's default
+ state at insufficient_cohort."""
+ for i in range(12):
+ _seed_member(db, f"untagged_{i}", node_count=20, alpha=0.3, sector=None)
+ res = pc.peer_alpha_benchmark(db, alpha=0.4, node_count=30)
+ assert res['status'] == 'insufficient_cohort'
+ assert res['n'] == 0
+ assert 'percentile' not in res
+
+
+# ---------------------------------------------------------------------------
+# 4. Sufficient cohort -> real percentile + stats
+# ---------------------------------------------------------------------------
+
+def test_sufficient_cohort_returns_real_percentile(db):
+ # 10 peers all in the 'small' bucket (node_count 10-49), alphas 0.10..0.55
+ alphas = [0.10, 0.15, 0.20, 0.25, 0.30, 0.35, 0.40, 0.45, 0.50, 0.55]
+ for i, a in enumerate(alphas):
+ _seed_member(db, f"peer_{i}", node_count=20, alpha=a)
+
+ # Org alpha = 0.40 sits above 7 peers -> upper part of the distribution.
+ res = pc.peer_alpha_benchmark(db, alpha=0.40, node_count=30)
+ assert res['status'] == 'ok'
+ assert res['n'] == 10
+ assert 0.0 <= res['percentile'] <= 100.0
+ assert res['percentile'] > 50.0
+ assert 'median' in res and 'q1' in res and 'q3' in res
+
+
+def test_cohort_excludes_self(db):
+ """The org's own stored record must not count as one of its peers."""
+ alphas = [0.10, 0.15, 0.20, 0.25, 0.30, 0.35, 0.40, 0.45, 0.50, 0.55]
+ ids = [_seed_member(db, f"peer_{i}", node_count=20, alpha=a)
+ for i, a in enumerate(alphas)]
+ # Excluding one member drops the cohort to 9 -> insufficient.
+ res = pc.peer_alpha_benchmark(db, alpha=0.4, node_count=30,
+ exclude_network_id=ids[0])
+ assert res['status'] == 'insufficient_cohort'
+ assert res['n'] == 9
+
+
+# ---------------------------------------------------------------------------
+# 5. Filters narrow the cohort
+# ---------------------------------------------------------------------------
+
+def test_sector_filter_narrows_cohort(db):
+ # 10 'tech' peers + 6 'finance' peers, all in the same size bucket.
+ for i in range(10):
+ _seed_member(db, f"tech_{i}", node_count=20, alpha=0.3, sector='tech')
+ for i in range(6):
+ _seed_member(db, f"fin_{i}", node_count=20, alpha=0.5, sector='finance')
+
+ tech = pc.peer_alpha_benchmark(db, alpha=0.35, node_count=30, sector='tech')
+ assert tech['status'] == 'ok'
+ assert tech['n'] == 10
+
+ fin = pc.peer_alpha_benchmark(db, alpha=0.55, node_count=30, sector='finance')
+ assert fin['status'] == 'insufficient_cohort' # only 6 finance peers
+ assert fin['n'] == 6
+
+
+def test_size_bucket_filter_narrows_cohort(db):
+ # 10 'small'-bucket peers + 4 'large'-bucket peers.
+ for i in range(10):
+ _seed_member(db, f"small_{i}", node_count=20, alpha=0.3)
+ for i in range(4):
+ _seed_member(db, f"large_{i}", node_count=500, alpha=0.5)
+
+ # Org with 30 nodes -> 'small' bucket -> only the 10 small peers match.
+ res = pc.peer_alpha_benchmark(db, alpha=0.35, node_count=30)
+ assert res['status'] == 'ok'
+ assert res['n'] == 10
+
+ # Org with 400 nodes -> 'large' bucket -> only 4 peers -> insufficient.
+ res_large = pc.peer_alpha_benchmark(db, alpha=0.4, node_count=400)
+ assert res_large['status'] == 'insufficient_cohort'
+ assert res_large['n'] == 4
+
+
+# ---------------------------------------------------------------------------
+# 6. query_cohort returns key metrics for members
+# ---------------------------------------------------------------------------
+
+def test_query_cohort_returns_member_metrics(db):
+ _seed_member(db, "m1", node_count=20, alpha=0.3, robustness=0.28, overall=55.0)
+ _seed_member(db, "m2", node_count=30, alpha=0.4, robustness=0.31, overall=65.0)
+ members = pc.query_cohort(db, size_bucket='small')
+ assert len(members) == 2
+ m = {x['name']: x for x in members}['m1']
+ assert m['metrics']['relative_ascendency'] == pytest.approx(0.3)
+ assert m['metrics']['robustness'] == pytest.approx(0.28)
+ assert m['metrics']['oasis_overall'] == pytest.approx(55.0)
+ assert m['size_bucket'] == 'small'
+
+
+# ---------------------------------------------------------------------------
+# 7. Honest fallback note (never fabricates numbers)
+# ---------------------------------------------------------------------------
+
+def test_note_for_insufficient_is_honest():
+ res = {'status': 'insufficient_cohort', 'n': 3, 'min': pc.MIN_COHORT_SIZE}
+ note = pc.format_peer_benchmark_note(res, alpha=0.4)
+ assert 'indicative' in note.lower()
+ assert 'N=3' in note
+ assert str(pc.MIN_COHORT_SIZE) in note
+ # must NOT claim a percentile
+ assert 'percentile' not in note.lower() or 'requires' in note.lower()
+
+
+def test_note_for_sufficient_reports_percentile():
+ res = {'status': 'ok', 'n': 12, 'percentile': 62.5,
+ 'median': 0.33, 'q1': 0.25, 'q3': 0.41}
+ note = pc.format_peer_benchmark_note(res, alpha=0.4)
+ assert '62' in note
+ assert '12' in note
+ assert 'percentile' in note.lower()
+
+
+# ---------------------------------------------------------------------------
+# 8. Ingestion path grows the cohort
+# ---------------------------------------------------------------------------
+
+def test_ingest_directory_grows_cohort(db, tmp_path):
+ import json
+ src = tmp_path / "nets"
+ src.mkdir()
+ # Two small valid networks written as JSON flow matrices.
+ for i in range(2):
+ m = np.array([[0, 5, 0], [0, 0, 3], [2, 0, 0]], dtype=float) + i
+ (src / f"net_{i}.json").write_text(json.dumps({
+ 'organization': f"IngestOrg_{i}",
+ 'flow_matrix': m.tolist(),
+ 'node_names': ['A', 'B', 'C'],
+ }))
+
+ pipeline = PrecomputePipeline(db_manager=db)
+ summary = pc.ingest_directory(str(src), sector='logistics',
+ pipeline=pipeline, db=db)
+ assert summary['ingested'] == 2
+ # Both networks are now persisted and tagged with the sector.
+ members = pc.query_cohort(db, sector='logistics')
+ assert len(members) == 2
+ assert all(x['sector'] == 'logistics' for x in members)
diff --git a/tests/test_precompute_parity.py b/tests/test_precompute_parity.py
new file mode 100644
index 0000000..afe8338
--- /dev/null
+++ b/tests/test_precompute_parity.py
@@ -0,0 +1,136 @@
+"""
+Parity test for the compute-once / read-thereafter wiring.
+
+The precompute wiring changes the SOURCE of displayed/reported values from a
+live recompute to a READ of the stored full profile. It must NOT change the
+math. This test asserts, for three sample organizations, that the values read
+from the precomputed profile are IDENTICAL to the values produced by a fresh
+computation with the same calculators/analyzers.
+
+Compared values:
+- OASIS overall score + 5 dimension scores + overall status
+- robustness, relative ascendency (alpha) [core / Ulanowicz]
+- a network-analysis metric (small-world sigma + density)
+
+Uses a throwaway SQLite DB so the real DB is never touched.
+"""
+
+import json
+from pathlib import Path
+
+import numpy as np
+import pytest
+
+from src.database.db_manager import DatabaseManager
+from src.database.precompute_pipeline import PrecomputePipeline
+
+
+SAMPLE_DIR = Path(__file__).resolve().parent.parent / "data" / "ecosystem_samples"
+SAMPLE_ORGS = [
+ "cone_spring_original",
+ "crystal_river_creek",
+ "chesapeake_bay_simplified",
+]
+
+
+def _load_sample(name):
+ data = json.load(open(SAMPLE_DIR / f"{name}.json"))
+ flow_matrix = np.array(data.get("flow_matrix", data.get("flows")), dtype=float)
+ node_names = data.get("node_names", data.get("nodes"))
+ org_name = data.get("organization", name)
+ return flow_matrix, node_names, org_name
+
+
+def _fresh_values(flow_matrix, node_names):
+ """Compute the compared values live, the way the pre-wiring app did."""
+ try:
+ from ulanowicz_calculator import UlanowiczCalculator
+ from oasis_calculator import OASISCalculator
+ from network_analyzer import AdvancedNetworkAnalyzer
+ from vectorized_metrics import get_all_vectorized_metrics
+ except ImportError:
+ from src.ulanowicz_calculator import UlanowiczCalculator
+ from src.oasis_calculator import OASISCalculator
+ from src.network_analyzer import AdvancedNetworkAnalyzer
+ from src.vectorized_metrics import get_all_vectorized_metrics
+
+ calc = UlanowiczCalculator(flow_matrix, node_names)
+ analyzer = AdvancedNetworkAnalyzer(flow_matrix, node_names)
+ oasis = OASISCalculator(calc, network_analyzer=analyzer)
+ profile = oasis.get_oasis_profile()
+ # Mirror _family_core: vectorized metrics, then extended overlay.
+ core = dict(get_all_vectorized_metrics(flow_matrix))
+ core.update(calc.get_extended_metrics())
+ na = analyzer.get_all_metrics()
+
+ return {
+ "oasis_overall": profile["overall_score"],
+ "oasis_status": profile["overall_status"],
+ "oasis_dims": dict(profile["dimension_scores"]),
+ "robustness": core.get("robustness"),
+ "alpha": core.get("relative_ascendency"),
+ "structural_information": core.get("structural_information"),
+ "na_sigma": na["small_world"]["small_world_sigma"],
+ "na_density": na["basic"]["density"],
+ }
+
+
+def _profile_values(profile):
+ """Extract the same compared values by READING the stored profile."""
+ oasis = profile["oasis"]
+ core = profile["core"]
+ na = profile["network_analysis"]
+ return {
+ "oasis_overall": oasis["overall_score"],
+ "oasis_status": oasis["overall_status"],
+ "oasis_dims": dict(oasis["dimension_scores"]),
+ "robustness": core.get("robustness"),
+ "alpha": core.get("relative_ascendency"),
+ "structural_information": core.get("structural_information"),
+ "na_sigma": na["small_world"]["small_world_sigma"],
+ "na_density": na["basic"]["density"],
+ }
+
+
+@pytest.fixture
+def pipeline(tmp_path):
+ db = DatabaseManager(db_path=str(tmp_path / "parity.db"))
+ return PrecomputePipeline(db_manager=db)
+
+
+@pytest.mark.parametrize("org", SAMPLE_ORGS)
+def test_read_from_profile_matches_fresh_compute(pipeline, org):
+ flow_matrix, node_names, org_name = _load_sample(org)
+
+ # Provision: compute + store the full profile once.
+ result = pipeline.get_full_profile(flow_matrix, node_names, org_name=org_name)
+ assert result["cache_hit"] is False
+ read = _profile_values(result["profile"])
+
+ # Fresh, independent computation.
+ fresh = _fresh_values(flow_matrix, node_names)
+
+ # Scalars must be numerically identical (same math, different source).
+ for key in ("oasis_overall", "robustness", "alpha",
+ "structural_information", "na_sigma", "na_density"):
+ r, f = read[key], fresh[key]
+ if r is None or f is None:
+ assert r == f, f"{org}: {key} None mismatch (read={r}, fresh={f})"
+ else:
+ assert r == pytest.approx(f, rel=0, abs=0), \
+ f"{org}: {key} mismatch (read={r}, fresh={f})"
+
+ # OASIS status + per-dimension scores.
+ assert read["oasis_status"] == fresh["oasis_status"], f"{org}: OASIS status differs"
+ for dim in ("open", "autonomous", "symbiotic", "intelligent", "sustainable"):
+ assert read["oasis_dims"][dim] == pytest.approx(fresh["oasis_dims"][dim], rel=0, abs=0), \
+ f"{org}: OASIS dim {dim} differs (read={read['oasis_dims'][dim]}, fresh={fresh['oasis_dims'][dim]})"
+
+
+@pytest.mark.parametrize("org", SAMPLE_ORGS)
+def test_second_read_is_cache_hit(pipeline, org):
+ """After provision, a second read HITs the store (does not recompute)."""
+ flow_matrix, node_names, org_name = _load_sample(org)
+ pipeline.get_full_profile(flow_matrix, node_names, org_name=org_name)
+ second = pipeline.get_full_profile(flow_matrix, node_names, org_name=org_name)
+ assert second["cache_hit"] is True
diff --git a/tests/test_published_metrics_provenance.py b/tests/test_published_metrics_provenance.py
new file mode 100644
index 0000000..0d530c7
--- /dev/null
+++ b/tests/test_published_metrics_provenance.py
@@ -0,0 +1,104 @@
+"""
+Data-provenance tests for the published-metrics reference database.
+
+These lock in the fix for the mislabeled "florida_bay" benchmark anchor, which
+previously stored relative ascendency alpha = 0.367 citing "Heymans et al. 2002"
+with a "subtropical seagrass / shallow marine" description. That value was
+unsourceable: Heymans, Ulanowicz & Bondavalli (2002), "Network analysis of the
+South Florida Everglades graminoid marshes and comparison with nearby cypress
+ecosystems", Ecological Modelling 149:5-23, is about a FRESHWATER graminoid marsh
+and a cypress swamp (not a marine seagrass bay) and never reports 0.367.
+
+The paper reports relative ascendency (alpha = A/C = ascendency as a percentage
+of development capacity) directly in prose on p.20, Section 3.3 "System-level
+analysis":
+ "... the relative ascendency of 52% for the graminoids is higher than any
+ such index they had encountered ... The relative ascendency of 34% reported
+ for the cypress is lower than most of the relative ascendencies calculated
+ by NETWRK ..."
+=> graminoid alpha = 0.52, cypress alpha = 0.34.
+"""
+
+import pytest
+
+from src.services import published_metrics_db as pdb
+
+
+# --- The mislabeled entry/value must be gone ---------------------------------
+
+def test_bogus_florida_bay_metrics_entry_removed():
+ """The Heymans-cited florida_bay published-metric anchor (alpha=0.367) is gone."""
+ assert "florida_bay" not in pdb.PUBLISHED_METRICS
+ assert pdb.get_published_metric("florida_bay", "relative_ascendency") is None
+
+
+def test_no_published_anchor_stores_the_1_over_e_value():
+ """0.367 (== 1/e used elsewhere) must not survive as a published alpha anchor."""
+ for net_id in pdb.list_networks():
+ ra = pdb.get_published_metric(net_id, "relative_ascendency")
+ if ra is not None:
+ assert abs(ra - 0.367) > 1e-3, (
+ f"{net_id} stores alphaโ0.367 (== 1/e); this was the unsourceable value"
+ )
+
+
+# --- The corrected, genuinely-sourced Heymans anchors ------------------------
+
+def test_everglades_graminoid_matches_heymans_p20():
+ # Heymans et al. 2002, Ecological Modelling 149:5-23, p.20 (Section 3.3):
+ # "relative ascendency of 52% for the graminoids"
+ alpha = pdb.get_published_metric("everglades_graminoid", "relative_ascendency")
+ assert alpha == pytest.approx(0.52, abs=1e-9)
+
+ info = pdb.get_network_info("everglades_graminoid")
+ assert info is not None
+ assert "Heymans" in info["source"]
+ assert info["page"] == 20
+ # Must be labeled as the freshwater graminoid marsh, NOT a marine seagrass bay.
+ notes = " ".join(info.get("notes", [])).lower()
+ assert "graminoid" in notes or "marsh" in notes
+ assert "seagrass" not in notes and "marine" not in notes
+
+
+def test_everglades_cypress_matches_heymans_p20():
+ # Heymans et al. 2002, p.20 (Section 3.3):
+ # "relative ascendency of 34% reported for the cypress"
+ alpha = pdb.get_published_metric("everglades_cypress", "relative_ascendency")
+ assert alpha == pytest.approx(0.34, abs=1e-9)
+
+ info = pdb.get_network_info("everglades_cypress")
+ assert info is not None
+ assert "Heymans" in info["source"]
+ assert info["page"] == 20
+ notes = " ".join(info.get("notes", [])).lower()
+ assert "cypress" in notes
+ assert "seagrass" not in notes and "marine" not in notes
+
+
+def test_relative_ascendency_is_a_c_ratio_in_unit_interval():
+ """alpha = A/C is a dimensionless ratio in [0, 1] for the corrected anchors."""
+ for net_id in ("everglades_graminoid", "everglades_cypress"):
+ metric = pdb.PUBLISHED_METRICS[net_id].metrics["relative_ascendency"]
+ assert metric.unit == "dimensionless"
+ assert 0.0 <= metric.value <= 1.0
+
+
+def test_reference_only_anchors_are_flagged():
+ """Prose-quoted anchors with no recomputable flow matrix are reference_only."""
+ for net_id in ("everglades_graminoid", "everglades_cypress"):
+ assert pdb.PUBLISHED_METRICS[net_id].reference_only is True
+ # ... and therefore intentionally have no NETWORK_DATA_FILES mapping.
+ assert net_id not in pdb.NETWORK_DATA_FILES
+
+
+def test_reference_only_anchors_are_skipped_by_validation_agent():
+ """The computational validator skips reference_only anchors (no ERROR)."""
+ from src.services.scientific_validation_agent import (
+ ScientificValidationAgent,
+ ValidationStatus,
+ )
+
+ agent = ScientificValidationAgent()
+ for net_id in ("everglades_graminoid", "everglades_cypress"):
+ result = agent.validate_network(net_id)
+ assert result.overall_status == ValidationStatus.SKIP
diff --git a/tests/test_published_value_base.py b/tests/test_published_value_base.py
new file mode 100644
index 0000000..11f784f
--- /dev/null
+++ b/tests/test_published_value_base.py
@@ -0,0 +1,238 @@
+"""
+Base-awareness tests for the OASIS published-value VALIDATION comparison layer.
+
+Background
+----------
+The Ulanowicz engine (`UlanowiczCalculator`) computes information-theoretic
+MAGNITUDES (Ascendency A, Development Capacity C, Overhead/Reserve Phi, Average
+Mutual Information AMI, flow diversity / statistical entropy H) with the natural
+logarithm -> the results are in **nats**. Several stored published reference
+values (e.g. Ulanowicz & Norden 1990 "cone_spring_original", Ulanowicz 1986
+"crystal_river_creek") are quoted in **bits** (log base 2), per the papers.
+
+Comparing a nats value against a bits value is a base mismatch: it fails every
+base-DEPENDENT magnitude even when the flow computation is otherwise sound.
+Base-INVARIANT metrics -- relative ascendency alpha = A/C, robustness, and any
+pure ratio -- cancel the log base and must NOT be converted.
+
+These tests lock in a single, explicit, per-metric, base-aware conversion in the
+comparison layer:
+
+ * ``nats_to_bits(x) = x / ln(2) = x * log2(e)`` (magnitude INCREASES; nats->bits)
+ * conversion is applied ONLY to base-dependent metrics of LOG2 networks
+ * base-invariant metrics and NATURAL/unknown-base networks are left untouched.
+
+The tests deliberately exercise the conversion LOGIC with controlled inputs, so
+they are independent of any data-provenance issues in the stored flow matrices.
+"""
+
+import math
+
+import pytest
+
+from src.services import published_metrics_db as pdb
+from src.services.published_metrics_db import LogBase
+from src.services.scientific_validation_agent import (
+ ScientificValidationAgent,
+ nats_to_bits,
+ ValidationStatus,
+)
+
+
+LN2 = math.log(2)
+
+
+# ---------------------------------------------------------------------------
+# 1. Conversion direction (hand-computed): nats -> bits
+# ---------------------------------------------------------------------------
+
+def test_nats_to_bits_hand_computed():
+ """ln(2) nats is exactly 1 bit; the helper reproduces the hand value."""
+ assert nats_to_bits(math.log(2)) == pytest.approx(1.0, rel=1e-12)
+
+
+def test_nats_to_bits_matches_log2_of_e_scaling():
+ """bits = nats / ln2 = nats * log2(e). Both forms must agree."""
+ for x in (0.5, 1.0, 1.623, 68191.0):
+ assert nats_to_bits(x) == pytest.approx(x / LN2, rel=1e-12)
+ assert nats_to_bits(x) == pytest.approx(x * math.log2(math.e), rel=1e-12)
+
+
+def test_nats_to_bits_increases_magnitude():
+ """Because ln2 < 1, converting nats->bits must INCREASE a positive value."""
+ for x in (1e-6, 1.0, 1000.0):
+ assert nats_to_bits(x) > x
+
+
+def test_wrong_direction_is_detectably_different():
+ """A guard: bits->nats (multiply by ln2) is NOT the same as nats->bits."""
+ x = 1.623
+ assert (x * LN2) != pytest.approx(nats_to_bits(x), rel=1e-6)
+
+
+# ---------------------------------------------------------------------------
+# 2. Base-dependence classification
+# ---------------------------------------------------------------------------
+
+def test_magnitudes_are_base_dependent():
+ for name in (
+ "ascendency",
+ "development_capacity",
+ "reserve",
+ "overhead",
+ "average_mutual_information",
+ "statistical_entropy",
+ "flow_diversity",
+ ):
+ assert pdb.is_base_dependent(name), f"{name} should be base-DEPENDENT"
+
+
+def test_ratios_and_indices_are_base_invariant():
+ for name in (
+ "relative_ascendency",
+ "ascendency_ratio",
+ "robustness",
+ "total_system_throughput",
+ "network_efficiency",
+ "finn_cycling_index",
+ "is_viable",
+ ):
+ assert not pdb.is_base_dependent(name), f"{name} should be base-INVARIANT"
+
+
+# ---------------------------------------------------------------------------
+# 3. Per-metric conversion applied only for LOG2 base-dependent metrics
+# ---------------------------------------------------------------------------
+
+@pytest.fixture
+def agent():
+ return ScientificValidationAgent()
+
+
+def test_log2_base_dependent_metric_is_converted(agent):
+ """A LOG2 network converts a base-dependent engine nats value to bits."""
+ value_nats = 100.0
+ converted = agent._convert_engine_value("ascendency", value_nats, LogBase.LOG2)
+ assert converted == pytest.approx(value_nats / LN2, rel=1e-12)
+
+
+def test_log2_base_invariant_metric_is_not_converted(agent):
+ """alpha and robustness must be compared raw even for a LOG2 network."""
+ for name in ("relative_ascendency", "robustness", "ascendency_ratio"):
+ raw = 0.505
+ assert agent._convert_engine_value(name, raw, LogBase.LOG2) == raw
+
+
+def test_natural_base_dependent_metric_is_not_force_converted(agent):
+ """A NATURAL-base network's magnitude stays in nats (no /ln2 applied)."""
+ value_nats = 53.9
+ assert agent._convert_engine_value(
+ "ascendency", value_nats, LogBase.NATURAL
+ ) == value_nats
+
+
+def test_unknown_base_is_not_force_converted(agent):
+ """Guard: an unrecognized/LOG10 base must not be silently divided by ln2."""
+ value_nats = 42.0
+ # LOG10 is a real enum member but no network uses it and the engine is nats;
+ # it must NOT be treated as if it were LOG2.
+ assert agent._convert_engine_value(
+ "ascendency", value_nats, LogBase.LOG10
+ ) == value_nats
+
+
+# ---------------------------------------------------------------------------
+# 4. End-to-end comparison: base reconciliation flips a nats/bits mismatch
+# from FAIL to PASS when the ONLY difference is the log base.
+# ---------------------------------------------------------------------------
+
+def test_comparison_passes_when_only_difference_is_base(agent):
+ """
+ Published value in bits, engine value the SAME quantity in nats.
+ After the base-aware conversion the comparison must PASS (0% error);
+ without conversion (raw nats vs bits) it must FAIL. This isolates the
+ base fix from any flow-data provenance issue.
+ """
+ published_bits = 68191.0
+ engine_nats = published_bits * LN2 # same physical quantity, in nats
+
+ # With base-aware conversion (LOG2 network, base-dependent metric):
+ converted = agent._convert_engine_value("ascendency", engine_nats, LogBase.LOG2)
+ passed = agent._compare_metric(
+ metric_name="ascendency",
+ published_value=published_bits,
+ computed_value=converted,
+ tolerance=0.05,
+ )
+ assert passed.status == ValidationStatus.PASS
+ assert passed.percent_error == pytest.approx(0.0, abs=1e-6)
+
+ # Without conversion (the historical nats-vs-bits bug): FAIL.
+ unconverted = agent._compare_metric(
+ metric_name="ascendency",
+ published_value=published_bits,
+ computed_value=engine_nats,
+ tolerance=0.05,
+ )
+ assert unconverted.status == ValidationStatus.FAIL
+
+
+def test_ami_bits_reconciliation_hand_value(agent):
+ """Cone-spring published AMI = 1.623 bits == 1.1249 nats; converting the
+ nats value back must reproduce 1.623 bits (right direction, hand-checked)."""
+ ami_nats = 1.623 * LN2 # 1.1249... nats
+ assert agent._convert_engine_value(
+ "average_mutual_information", ami_nats, LogBase.LOG2
+ ) == pytest.approx(1.623, rel=1e-9)
+
+
+# ---------------------------------------------------------------------------
+# 5. Real-network wiring: for a LOG2 network the comparison value is in bits;
+# for base-invariant alpha it is unchanged.
+# ---------------------------------------------------------------------------
+
+def test_cone_spring_base_dependent_comparison_is_in_bits(agent):
+ """
+ For the LOG2 cone_spring_original network, the value the comparison layer
+ puts up against the published (bits) ascendency must be the engine nats
+ value converted to bits -- NOT the raw nats value. (We assert the wiring,
+ not a published-value match: the stored internal-only flow matrix does not
+ reproduce the paper's full-system magnitude -- a separate data-provenance
+ issue documented in the provenance tests.)
+ """
+ result = agent.validate_network("cone_spring_original")
+ comps = {c.metric_name: c for c in result.metric_comparisons}
+
+ engine_nats_A = result.computed_metrics["ascendency"]
+ asc = comps["ascendency"]
+ assert asc.computed_value == pytest.approx(nats_to_bits(engine_nats_A), rel=1e-9)
+ # And it is strictly larger than the raw nats value (conversion happened).
+ assert asc.computed_value > engine_nats_A
+
+
+def test_cone_spring_alpha_comparison_is_raw(agent):
+ """Base-invariant alpha for the same LOG2 network is compared WITHOUT
+ conversion: the compared value equals the engine's relative_ascendency."""
+ result = agent.validate_network("cone_spring_original")
+ comps = {c.metric_name: c for c in result.metric_comparisons}
+ engine_alpha = result.computed_metrics["relative_ascendency"]
+ assert comps["relative_ascendency"].computed_value == pytest.approx(
+ engine_alpha, rel=1e-12
+ )
+
+
+def test_natural_network_ascendency_comparison_is_raw(agent):
+ """A NATURAL-base network (prawns) must compare ascendency in raw nats."""
+ result = agent.validate_network("prawns_alligator_original")
+ comps = {c.metric_name: c for c in result.metric_comparisons}
+ engine_nats_A = result.computed_metrics["ascendency"]
+ assert comps["ascendency"].computed_value == pytest.approx(
+ engine_nats_A, rel=1e-12
+ )
+
+
+def test_reference_only_networks_still_skip(agent):
+ """Everglades reference anchors have no flow matrix -> SKIP, unchanged."""
+ for net in ("everglades_graminoid", "everglades_cypress"):
+ result = agent.validate_network(net)
+ assert result.overall_status == ValidationStatus.SKIP
diff --git a/tests/test_report_consistency.py b/tests/test_report_consistency.py
new file mode 100644
index 0000000..d71c7fd
--- /dev/null
+++ b/tests/test_report_consistency.py
@@ -0,0 +1,87 @@
+"""
+Track-1 PART-2 consistency fixes โ single-source-of-truth guards.
+
+Covers:
+ E-19 efficiency-label / risk-framing alignment (viability-anchored bands)
+ E-20 robustness "high" threshold unified across report paths
+ E-21 appendix "Network Efficiency" text = alpha = A/C
+ E-27 one density definition (directed connectance)
+"""
+import os
+import sys
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
+
+import report_intelligence as ri
+
+
+# ---------------------------------------------------------------------------
+# E-19 โ efficiency bands are viability-anchored; HIGH efficiency is NOT "good"
+# ---------------------------------------------------------------------------
+
+def test_efficiency_bands_single_source():
+ assert ri.EFFICIENCY_BAND_LOWER == ri.VIABILITY_LOWER == 0.2
+ assert ri.EFFICIENCY_BAND_UPPER == ri.VIABILITY_UPPER == 0.6
+ assert ri.EFFICIENCY_BAND_DEVELOPING == 0.35
+ assert ri.EFFICIENCY_BAND_OPTIMAL == 0.45
+
+
+def test_efficiency_label_matches_risk_framing():
+ # Below window -> under-organized/chaotic
+ assert ri.categorize_efficiency_label(0.10) == "Under-organized"
+ # In-window sub-bands
+ assert ri.categorize_efficiency_label(0.30) == "Developing"
+ assert ri.categorize_efficiency_label(0.40) == "Optimal"
+ assert ri.categorize_efficiency_label(0.50) == "Efficient"
+ # Above window -> over-organized/brittle (NOT "good"/"Very High")
+ label = ri.categorize_efficiency_label(0.70)
+ assert label == "Over-organized"
+ assert "high" not in label.lower() and "good" not in label.lower()
+
+
+def test_publication_and_latex_share_efficiency_bands():
+ """Both report generators must resolve to the same label function."""
+ from publication_report import PublicationReportGenerator # noqa: F401
+ # publication_report._categorize_efficiency delegates to ri; verify parity
+ # at a representative over-organized alpha.
+ assert ri.categorize_efficiency_label(0.7) == "Over-organized"
+ assert ri.categorize_efficiency_label(0.4) == "Optimal"
+
+
+# ---------------------------------------------------------------------------
+# E-20 โ robustness "high" threshold unified (0.25)
+# ---------------------------------------------------------------------------
+
+def test_robustness_high_threshold_single_value():
+ assert ri.ROBUSTNESS_HIGH_THRESHOLD == 0.25
+ # Just below the unified "high" rung -> not "Very High"
+ assert ri.categorize_robustness_label(0.22) == "High"
+ assert ri.categorize_robustness_label(0.26) == "Very High"
+ assert ri.categorize_robustness_label(0.19) == "Moderate"
+
+
+# ---------------------------------------------------------------------------
+# E-21 โ appendix formula text corrected
+# ---------------------------------------------------------------------------
+
+def test_appendix_network_efficiency_text():
+ path = os.path.join(os.path.dirname(__file__), '..', 'src', 'publication_report.py')
+ with open(path, 'r') as f:
+ src = f.read()
+ assert "Network Efficiency: alpha = A / C" in src
+ assert "A / (C x log2(n))" not in src
+
+
+# ---------------------------------------------------------------------------
+# E-27 โ a single density definition (directed connectance)
+# ---------------------------------------------------------------------------
+
+def test_single_density_definition_in_precompute():
+ path = os.path.join(os.path.dirname(__file__), '..', 'src', 'database',
+ 'precompute_pipeline.py')
+ with open(path, 'r') as f:
+ src = f.read()
+ # The duplicate m/n^2 density must be gone.
+ assert "num_edges / (n_nodes * n_nodes)" not in src
+ # network_density is aliased to the single connectance definition.
+ assert "metrics['network_density'] = metrics['connectance']" in src
diff --git a/tests/test_report_intelligence.py b/tests/test_report_intelligence.py
new file mode 100644
index 0000000..ba90a08
--- /dev/null
+++ b/tests/test_report_intelligence.py
@@ -0,0 +1,155 @@
+from src import report_intelligence as ri
+
+
+def _profile(overall=72.0, status='HEALTHY'):
+ return {
+ 'dimension_scores': {'open': 70, 'autonomous': 55, 'symbiotic': 80,
+ 'intelligent': 60, 'sustainable': 78},
+ 'dimension_status': {'open': 'HEALTHY', 'autonomous': 'WARNING',
+ 'symbiotic': 'HEALTHY', 'intelligent': 'WARNING',
+ 'sustainable': 'HEALTHY'},
+ 'dimension_details': {'sustainable': {'metrics': {
+ 'relative_ascendency': 0.42, 'robustness': 0.36, 'is_viable': True}}},
+ 'overall_score': overall, 'overall_status': status,
+ 'weights': {'open': 0.2, 'autonomous': 0.2, 'symbiotic': 0.2,
+ 'intelligent': 0.2, 'sustainable': 0.2},
+ }
+
+
+def _metrics(alpha=0.42, robustness=0.36):
+ return {'ascendency_ratio': alpha, 'robustness': robustness,
+ 'development_capacity': 100.0, 'ascendency': 42.0,
+ 'overhead_ratio': 1 - alpha, 'redundancy': 0.5}
+
+
+def _recs():
+ return [
+ {'priority': 'CRITICAL', 'dimension': 'SUSTAINABLE', 'issue': 'Too rigid',
+ 'action': 'Diversify pathways', 'metrics_to_improve': ['redundancy']},
+ {'priority': 'HIGH', 'dimension': 'OPEN', 'issue': 'Low interconnectivity',
+ 'action': 'Add cross-functional channels', 'metrics_to_improve': ['connectance']},
+ {'priority': 'MEDIUM', 'dimension': 'SYMBIOTIC', 'issue': 'Inequality',
+ 'action': 'Redistribute resources', 'metrics_to_improve': ['gini_coefficient']},
+ ]
+
+
+# --- Task 1: constants + verdict ---
+
+def test_constants_match_codebase_window():
+ assert ri.VIABILITY_LOWER == 0.2
+ assert ri.VIABILITY_UPPER == 0.6
+ assert abs(ri.ROBUSTNESS_OPTIMUM - 0.367879441) < 1e-6
+
+
+def test_executive_verdict_mentions_score_and_status():
+ v = ri.executive_verdict(_profile(overall=72.0, status='HEALTHY'))
+ assert '72' in v
+ assert 'HEALTHY' in v.upper()
+
+
+def test_executive_verdict_handles_empty_profile():
+ assert isinstance(ri.executive_verdict({}), str)
+
+
+# --- Task 2: benchmark ---
+
+def test_benchmark_view_position_in_window():
+ v = ri.build_benchmark_view(_metrics(alpha=0.42), _profile())
+ assert v['alpha'] == 0.42
+ assert v['in_window'] is True
+ assert v['lower'] == 0.2 and v['upper'] == 0.6
+ assert abs(v['distance_to_optimum'] - abs(0.42 - ri.ROBUSTNESS_OPTIMUM)) < 1e-9
+ assert isinstance(v['reference_anchors'], list)
+
+
+def test_benchmark_view_out_of_window_rigid():
+ v = ri.build_benchmark_view(_metrics(alpha=0.7), _profile())
+ assert v['in_window'] is False
+ assert v['position'] == 'above'
+
+
+def test_benchmark_view_handles_missing_metrics():
+ v = ri.build_benchmark_view({}, {})
+ assert 'alpha' in v and 'reference_anchors' in v
+
+
+# --- Task 3: risk ---
+
+def test_risk_view_brittle_when_alpha_high():
+ v = ri.build_risk_view(_metrics(alpha=0.72), _profile())
+ assert v['fragility'] == 'over-organized'
+ assert any('rigid' in item['title'].lower() or 'brittle' in item['title'].lower()
+ for item in v['items'])
+
+
+def test_risk_view_chaotic_when_alpha_low():
+ v = ri.build_risk_view(_metrics(alpha=0.12), _profile())
+ assert v['fragility'] == 'under-organized'
+
+
+def test_risk_view_balanced_in_window():
+ v = ri.build_risk_view(_metrics(alpha=0.4), _profile())
+ assert v['fragility'] == 'balanced'
+
+
+def test_risk_view_flags_critical_dimensions():
+ prof = _profile()
+ prof['dimension_status']['autonomous'] = 'CRITICAL'
+ v = ri.build_risk_view(_metrics(alpha=0.4), prof)
+ assert any(it['severity'] == 'CRITICAL' for it in v['items'])
+
+
+def test_risk_view_handles_empty():
+ v = ri.build_risk_view({}, {})
+ assert 'fragility' in v and isinstance(v['items'], list)
+
+
+# --- Task 4: roadmap ---
+
+def test_roadmap_buckets_by_horizon():
+ r = ri.build_action_roadmap(_recs(), _profile())
+ assert len(r['immediate']) == 1 and r['immediate'][0]['dimension'] == 'SUSTAINABLE'
+ assert len(r['short_term']) == 1 and r['short_term'][0]['dimension'] == 'OPEN'
+ assert len(r['medium_term']) == 1
+
+
+def test_roadmap_items_carry_expected_impact():
+ r = ri.build_action_roadmap(_recs(), _profile())
+ assert 'expected_impact' in r['immediate'][0]
+
+
+def test_roadmap_handles_no_recs():
+ r = ri.build_action_roadmap([], _profile())
+ assert r['immediate'] == [] and r['short_term'] == [] and r['medium_term'] == []
+
+
+# --- Task 5: ESG crosswalk ---
+
+def test_esg_crosswalk_covers_all_dimensions():
+ rows = ri.build_esg_crosswalk(_profile(), _metrics())
+ dims = {row['oasis_dimension'] for row in rows}
+ assert {'OPEN', 'AUTONOMOUS', 'SYMBIOTIC', 'INTELLIGENT', 'SUSTAINABLE'} <= dims
+
+
+def test_esg_crosswalk_rows_have_framework_refs():
+ rows = ri.build_esg_crosswalk(_profile(), _metrics())
+ r = rows[0]
+ assert all(k in r for k in ('gri_ref', 'esrs_ref', 'tcfd_ref', 'finding_summary'))
+
+
+def test_esg_crosswalk_handles_empty_profile():
+ rows = ri.build_esg_crosswalk({}, {})
+ assert len(rows) == 5
+
+
+# --- Task 6: WoV chart ---
+
+def test_wov_chart_returns_png_bytes():
+ png = ri.render_window_of_viability_png(alpha=0.42, robustness=0.36)
+ assert isinstance(png, (bytes, bytearray))
+ assert png[:8] == b'\x89PNG\r\n\x1a\n'
+
+
+def test_wov_chart_handles_zero_alpha():
+ png = ri.render_window_of_viability_png(alpha=0.0, robustness=0.0)
+ assert png[:8] == b'\x89PNG\r\n\x1a\n'
diff --git a/tests/test_report_proofing.py b/tests/test_report_proofing.py
new file mode 100644
index 0000000..bbafe31
--- /dev/null
+++ b/tests/test_report_proofing.py
@@ -0,0 +1,139 @@
+"""
+Credibility / "unproofed draft" defect tests for the OASIS PDF report.
+
+These guard against the class of errors a client CFO spots instantly:
+ 1. Mis-numbered subsection headings (Discussion 9.x, Conclusions 10.x).
+ 2. Table of Contents entries that do not match real body headings.
+ 3. Raw code identifiers (relative_ascendency, number_of_roles, ...) leaking
+ into user-facing recommendation / narrative text.
+ 4. Benchmark framing that leads with ecological wetlands instead of the
+ Fath (2019) organizational anchor (alpha 0.30-0.45).
+"""
+import io
+import re
+
+import numpy as np
+import pytest
+
+from src.ulanowicz_calculator import UlanowiczCalculator
+from src.publication_report import PublicationReportGenerator
+from src.pdf_generator import generate_pdf_report
+
+
+# ---------------------------------------------------------------------------
+# Fixtures / helpers
+# ---------------------------------------------------------------------------
+def _make_generator():
+ flow = np.array([
+ [0, 10, 0, 0, 5],
+ [0, 0, 8, 2, 0],
+ [0, 0, 0, 7, 1],
+ [3, 0, 0, 0, 6],
+ [0, 4, 0, 0, 0],
+ ], dtype=float)
+ nodes = ['A', 'B', 'C', 'D', 'E']
+ calc = UlanowiczCalculator(flow, nodes)
+ metrics = calc.get_extended_metrics()
+ assessments = calc.assess_regenerative_health()
+ rg = PublicationReportGenerator(
+ calculator=calc, metrics=metrics, assessments=assessments,
+ org_name='Test Org', flow_matrix=flow, node_names=nodes)
+ return rg, calc, metrics
+
+
+def _render_pdf_text():
+ rg, calc, metrics = _make_generator()
+ pdf = generate_pdf_report(rg, calc, metrics, charts=None)
+ from pypdf import PdfReader
+ reader = PdfReader(io.BytesIO(pdf))
+ return "\n".join((page.extract_text() or "") for page in reader.pages)
+
+
+# ---------------------------------------------------------------------------
+# 1. Subsection heading numbering
+# ---------------------------------------------------------------------------
+def test_discussion_subheadings_are_9x():
+ rg, _, _ = _make_generator()
+ disc = rg.generate_discussion()
+ # No leaked earlier-section numbering
+ assert not re.search(r'(?m)^\s*4\.\d\s', disc), \
+ "Discussion still contains 4.x subsection headers"
+ # Real parent is section 9
+ assert re.search(r'(?m)^\s*9\.1\s', disc)
+ assert re.search(r'(?m)^\s*9\.2\s', disc)
+ assert re.search(r'(?m)^\s*9\.3\s', disc)
+
+
+def test_conclusions_subheadings_are_10x():
+ rg, _, _ = _make_generator()
+ conc = rg.generate_conclusions()
+ assert not re.search(r'(?m)^\s*5\.\d\s', conc), \
+ "Conclusions still contains 5.x subsection headers"
+ assert re.search(r'(?m)^\s*10\.1\s', conc)
+ assert re.search(r'(?m)^\s*10\.2\s', conc)
+ assert re.search(r'(?m)^\s*10\.3\s', conc)
+
+
+# ---------------------------------------------------------------------------
+# 2. Table of Contents matches the real body
+# ---------------------------------------------------------------------------
+def test_toc_has_no_phantom_entries():
+ from src.pdf_generator import build_toc_items
+ titles = [t for t, _ in build_toc_items()]
+ joined = " | ".join(titles)
+ assert "Network Structure" not in joined
+ assert "System Organization" not in joined
+
+
+def test_toc_entries_match_body_headings():
+ """Every TOC entry must correspond to an actual heading rendered in the body."""
+ from src.pdf_generator import build_toc_items, BODY_HEADINGS
+ toc_titles = {t.strip() for t, _ in build_toc_items()}
+ body = {h.strip() for h in BODY_HEADINGS}
+ missing = toc_titles - body
+ assert not missing, f"TOC entries with no matching body heading: {missing}"
+
+
+def test_toc_body_headings_appear_in_rendered_pdf():
+ text = _render_pdf_text()
+ from src.pdf_generator import BODY_HEADINGS
+ # Top-level numbered sections must literally appear in the rendered PDF.
+ for h in BODY_HEADINGS:
+ if re.match(r'^\d+\.\s', h) or h in (
+ 'Executive Summary', 'References'):
+ token = h.split('&')[0].strip()[:18]
+ assert token in text, f"body heading not found in PDF: {h!r}"
+
+
+# ---------------------------------------------------------------------------
+# 3. No leaked metric identifiers in user-facing text
+# ---------------------------------------------------------------------------
+LEAKED = ['relative_ascendency', 'number_of_roles', 'finn_cycling_index',
+ 'regenerative_capacity', 'flow_diversity', 'gini_coefficient',
+ 'clustering_coefficient', 'flow_reciprocity', 'mutualism_ratio',
+ 'functional_diversity', 'overhead_ratio', 'connectance']
+
+
+def test_no_leaked_identifiers_in_pdf_text():
+ text = _render_pdf_text()
+ hits = [ident for ident in LEAKED if ident in text]
+ assert not hits, f"Raw metric identifiers leaked into report text: {hits}"
+
+
+def test_recommendation_metrics_humanized():
+ from src.pdf_generator import humanize_metric_name
+ assert humanize_metric_name('number_of_roles') == 'number of functional roles'
+ assert 'relative ascendency' in humanize_metric_name('relative_ascendency')
+ assert '_' not in humanize_metric_name('finn_cycling_index')
+
+
+# ---------------------------------------------------------------------------
+# 4. Benchmark framing: Fath organizational anchor is primary
+# ---------------------------------------------------------------------------
+def test_benchmark_leads_with_org_anchor():
+ text = _render_pdf_text()
+ # Fath organizational anchor present in benchmark framing
+ assert 'Fath' in text
+ assert '0.30' in text and '0.45' in text
+ # Ecological anchors must be captioned as illustrative reference points.
+ assert re.search(r'illustrative', text, re.IGNORECASE)
diff --git a/tests/test_report_sections.py b/tests/test_report_sections.py
new file mode 100644
index 0000000..00c3ea8
--- /dev/null
+++ b/tests/test_report_sections.py
@@ -0,0 +1,50 @@
+import numpy as np
+from src.ulanowicz_calculator import UlanowiczCalculator
+from src.oasis_calculator import OASISCalculator
+from src.oasis_pdf_report import OASISPDFReport
+
+
+def _build_report(detailed=True):
+ flow = np.array([
+ [0, 10, 0, 0, 5],
+ [0, 0, 8, 2, 0],
+ [0, 0, 0, 7, 1],
+ [3, 0, 0, 0, 6],
+ [0, 4, 0, 0, 0],
+ ], dtype=float)
+ nodes = ['A', 'B', 'C', 'D', 'E']
+ uc = UlanowiczCalculator(flow, nodes)
+ oc = OASISCalculator(uc)
+ return OASISPDFReport(
+ org_name='Test Org',
+ oasis_profile=oc.get_oasis_profile(),
+ ulanowicz_metrics=uc.get_extended_metrics(),
+ interpretations=oc.get_oasis_interpretation(),
+ recommendations=oc.get_recommendations(),
+ detailed=detailed,
+ )
+
+
+def test_detailed_report_contains_new_sections():
+ html = _build_report(detailed=True).generate_html()
+ assert 'Benchmarking' in html
+ assert 'Risk & Resilience' in html or 'Risk & Resilience' in html
+ assert 'Action Roadmap' in html
+ assert 'Framework Mapping' in html or 'ESG' in html
+
+
+def test_lean_report_excludes_new_sections():
+ html = _build_report(detailed=False).generate_html()
+ assert 'Action Roadmap' not in html
+
+
+def test_convenience_function_detailed_default(tmp_path):
+ from src.oasis_pdf_report import generate_oasis_pdf_report
+
+ flow = np.array([[0, 10, 5], [2, 0, 8], [6, 1, 0]], dtype=float)
+ uc = UlanowiczCalculator(flow, ['A', 'B', 'C'])
+ oc = OASISCalculator(uc)
+ out = tmp_path / "r.pdf"
+ generate_oasis_pdf_report(oc, uc, org_name='X', output_path=str(out))
+ html = (tmp_path / "r.html").read_text()
+ assert 'Action Roadmap' in html # detailed=True is the default
diff --git a/tests/test_rollup_veto.py b/tests/test_rollup_veto.py
new file mode 100644
index 0000000..1357092
--- /dev/null
+++ b/tests/test_rollup_veto.py
@@ -0,0 +1,119 @@
+"""
+Tests for the OASIS composite roll-up "band cap" veto.
+
+Rule (dimension-agnostic worst-dimension band cap):
+ Order bands CRITICAL=0 < WARNING=1 < HEALTHY=2.
+ - raw_overall_level from weighted mean (>=60 HEALTHY / >=40 WARNING / else CRITICAL)
+ - each dimension's level from HEALTH_THRESHOLDS / get_status
+ - worst_dim_level = min(level over 5 dimensions)
+ - final_overall_level = min(raw_overall_level, worst_dim_level + 1)
+ - numeric overall score is UNCHANGED; only the label is capped.
+ - `capped_by` lists the dimension(s) that drove the cap.
+
+The "Non-Viable org labeled HEALTHY" contradiction:
+ (OPEN 100, AUT 100, SYM 100, INT 100, SUSTAINABLE 0) -> weighted mean 80
+ Raw -> HEALTHY, but SUSTAINABLE is CRITICAL so overall must be capped to WARNING.
+"""
+
+from src.oasis_calculator import OASISCalculator
+
+
+# Scores chosen so each dimension lands in the intended band via HEALTH_THRESHOLDS.
+# open healthy>=50, autonomous>=40, symbiotic>=55, intelligent>=45, sustainable>=60.
+ALL_HEALTHY = {
+ 'open': 100.0,
+ 'autonomous': 100.0,
+ 'symbiotic': 100.0,
+ 'intelligent': 100.0,
+ 'sustainable': 100.0,
+}
+
+
+def _apply(scores, weights=None):
+ """Invoke the pure roll-up logic under test."""
+ return OASISCalculator.compute_overall_status(scores, weights)
+
+
+def test_non_viable_org_not_labeled_healthy():
+ """(100,100,100,100, SUSTAINABLE critical) -> mean ~80 but overall WARNING not HEALTHY."""
+ scores = dict(ALL_HEALTHY, sustainable=0.0) # sustainable critical (<40)
+ result = _apply(scores)
+
+ assert abs(result['overall_score'] - 80.0) < 1e-6, result['overall_score']
+ assert result['raw_overall_status'] == 'HEALTHY'
+ assert result['overall_status'] == 'WARNING'
+ assert result['capped'] is True
+ assert 'sustainable' in result['capped_by']
+
+
+def test_all_dimensions_healthy_overall_healthy():
+ """All dims HEALTHY -> overall HEALTHY, no cap applied."""
+ result = _apply(ALL_HEALTHY)
+ assert result['overall_status'] == 'HEALTHY'
+ assert result['capped'] is False
+ assert result['capped_by'] == []
+
+
+def test_one_warning_rest_healthy_stays_healthy():
+ """One dim WARNING (worst=1), rest HEALTHY, high mean -> overall may stay HEALTHY."""
+ # autonomous in warning band [25,40); rest very high -> mean still >= 60.
+ scores = dict(ALL_HEALTHY, autonomous=30.0)
+ result = _apply(scores)
+ assert result['raw_overall_status'] == 'HEALTHY'
+ # worst_dim_level = 1 (WARNING); +1 = 2 (HEALTHY) allowed -> not capped down.
+ assert result['overall_status'] == 'HEALTHY'
+ assert result['capped'] is False
+
+
+def test_two_dimensions_critical_capped_at_warning():
+ """Two dims CRITICAL -> still capped at WARNING (worst=0, +1=1)."""
+ scores = dict(ALL_HEALTHY, sustainable=0.0, symbiotic=0.0)
+ result = _apply(scores)
+ assert result['overall_status'] == 'WARNING'
+ assert result['capped'] is True
+ assert set(result['capped_by']) == {'sustainable', 'symbiotic'}
+
+
+def test_numeric_score_unchanged_by_cap():
+ """The cap changes only the label, never the numeric weighted-mean score."""
+ scores = dict(ALL_HEALTHY, sustainable=0.0)
+ result = _apply(scores)
+ expected_mean = sum(scores[d] * 0.20 for d in scores)
+ assert abs(result['overall_score'] - expected_mean) < 1e-6
+
+
+def test_low_mean_stays_critical_even_if_dims_ok():
+ """If the weighted mean itself is CRITICAL, cap never raises it above raw."""
+ # All dims warning-ish but mean < 40 -> raw CRITICAL. worst_dim_level+1 can't raise it.
+ scores = {
+ 'open': 35.0, # warning
+ 'autonomous': 30.0, # warning
+ 'symbiotic': 40.0, # warning
+ 'intelligent': 32.0, # warning
+ 'sustainable': 45.0, # warning
+ }
+ result = _apply(scores)
+ # mean = 36.4 -> CRITICAL raw; min(CRITICAL, WARNING+1=HEALTHY) = CRITICAL
+ assert result['raw_overall_status'] == 'CRITICAL'
+ assert result['overall_status'] == 'CRITICAL'
+
+
+def test_profile_integration_exposes_capped_fields():
+ """The full get_oasis_profile() path exposes the new fields."""
+ import numpy as np
+ from src.ulanowicz_calculator import UlanowiczCalculator
+
+ flow = np.array([
+ [0, 10, 0, 0],
+ [0, 0, 8, 0],
+ [0, 0, 0, 6],
+ [4, 0, 0, 0],
+ ], dtype=float)
+ uc = UlanowiczCalculator(flow, node_names=['A', 'B', 'C', 'D'])
+ profile = OASISCalculator(uc).get_oasis_profile()
+
+ assert 'overall_status' in profile
+ assert 'raw_overall_status' in profile
+ assert 'overall_status_capped' in profile
+ assert 'capped_by' in profile
+ assert isinstance(profile['capped_by'], list)
diff --git a/tests/test_size_normalization.py b/tests/test_size_normalization.py
new file mode 100644
index 0000000..7e31f8f
--- /dev/null
+++ b/tests/test_size_normalization.py
@@ -0,0 +1,161 @@
+"""
+Tests for size-relative normalization in the OASIS composite (src/oasis_calculator.py).
+
+Scope (per the size-normalization task, grounded in
+docs/business-revision/evidence/expert-org-management.md ยง3b):
+
+ A. Role normalization is SIZE-RELATIVE and PRINCIPLED:
+ norm_roles = min(number_of_roles / effective_nodes, 1)
+ Roles R = exp(AMI) is bounded above by the effective number of nodes N
+ (R = N/C with the effective connectivity C >= 1 connectivity floor,
+ Ulanowicz 2004; Zorach & Ulanowicz 2003). So R/N in [0, 1] is a
+ principled, size-relative normalizer replacing the fixed `roles/10`.
+ `rolesPerNode` (R/N) is itself already <= 1 by the same bound, so it is
+ normalized by the principled max of 1.0 (drop the arbitrary `/2`).
+
+ B. The per-dimension caps are CENTRALIZED and DOCUMENTED (values unchanged
+ unless a principled theoretical max exists) via DIMENSION_NORMALIZATION_CAPS.
+
+ C. The autocatalysis sub-term is DE-SATURATED: the arbitrary `cycle_flow_ratio
+ * 10` amplifier is removed so a network with a modest cycled-flow fraction
+ no longer pins the sub-term to 1.0.
+"""
+
+import math
+import numpy as np
+import pytest
+
+from src.ulanowicz_calculator import UlanowiczCalculator
+from src.oasis_calculator import OASISCalculator
+
+
+def _make_ring(n: int, base_flow: float = 100.0) -> np.ndarray:
+ """A simple directed ring on n nodes (structurally self-similar across n).
+
+ Every node sends `base_flow` to the next node; the only structural
+ difference between two rings is their size n. This is the cleanest way to
+ check that a normalizer is size-FAIR: the per-node structure is identical,
+ so a size-fair role score must be comparable across n.
+ """
+ fm = np.zeros((n, n), dtype=float)
+ for i in range(n):
+ fm[i, (i + 1) % n] = base_flow
+ return fm
+
+
+def _oasis_for(fm: np.ndarray) -> OASISCalculator:
+ calc = UlanowiczCalculator(fm)
+ return OASISCalculator(calc)
+
+
+# ---------------------------------------------------------------------------
+# A. Size-relative role normalization
+# ---------------------------------------------------------------------------
+
+def test_norm_roles_equals_roles_over_effective_nodes():
+ """norm_roles must be exactly roles / effective_nodes (principled bound)."""
+ fm = _make_ring(8)
+ oasis = _oasis_for(fm)
+ result = oasis.calculate_intelligent_score()
+ m = result['metrics']
+
+ roles = m['number_of_roles']
+ eff_nodes = oasis.ulanowicz.calculate_effective_nodes()
+ expected = min(roles / eff_nodes, 1.0)
+
+ assert m['norm_roles'] == pytest.approx(expected, abs=1e-9)
+
+
+def test_norm_roles_in_unit_interval():
+ for n in (3, 5, 8, 20):
+ fm = _make_ring(n)
+ oasis = _oasis_for(fm)
+ norm = oasis.calculate_intelligent_score()['metrics']['norm_roles']
+ assert 0.0 <= norm <= 1.0
+
+
+def test_norm_roles_is_size_fair_small_vs_large():
+ """A small (n=5) and a larger (n=20) structurally-similar network must get
+ COMPARABLE normalized role scores โ the fixed `/10` systematically penalized
+ the small net and inflated the large one. With roles/effective_nodes the two
+ are size-fair (within a small tolerance)."""
+ small = _oasis_for(_make_ring(5)).calculate_intelligent_score()['metrics']
+ large = _oasis_for(_make_ring(20)).calculate_intelligent_score()['metrics']
+
+ # Under the OLD fixed /10 rule the two norm_roles would differ substantially
+ # because roles ~ exp(AMI) grows with n while the divisor stayed fixed at 10.
+ # Under roles/effective_nodes they are comparable.
+ assert small['norm_roles'] == pytest.approx(large['norm_roles'], abs=0.15)
+
+
+def test_norm_roles_per_node_dropped_arbitrary_half():
+ """roles_per_node (R/N) is already <= 1 by the R <= N bound, so it must be
+ normalized by the principled max of 1.0 (i.e. min(rpn, 1)), NOT the old /2."""
+ fm = _make_ring(6)
+ oasis = _oasis_for(fm)
+ m = oasis.calculate_intelligent_score()['metrics']
+ rpn = m['roles_per_node']
+ assert m['norm_roles_per_node'] == pytest.approx(min(rpn, 1.0), abs=1e-9)
+
+
+# ---------------------------------------------------------------------------
+# B. Centralized, documented caps
+# ---------------------------------------------------------------------------
+
+def test_dimension_caps_are_centralized_and_documented():
+ caps = OASISCalculator.DIMENSION_NORMALIZATION_CAPS
+ assert set(caps.keys()) == {
+ 'open', 'autonomous', 'symbiotic', 'intelligent', 'sustainable'
+ }
+ for v in caps.values():
+ assert 0.0 < v <= 1.0
+
+
+def test_dimension_caps_drive_the_normalization():
+ """The dimension scores must read their cap from the central config, so a
+ future empirical re-calibration is a one-line change. We verify by checking
+ an all-ones raw sub-score would saturate at the documented cap for each dim.
+ (We check the config is actually consumed, not just present.)"""
+ caps = OASISCalculator.DIMENSION_NORMALIZATION_CAPS
+ # Cap values are unchanged in this task unless principled; assert the
+ # documented baseline set is present (guards against silent value drift).
+ assert caps['open'] == pytest.approx(0.6)
+ assert caps['autonomous'] == pytest.approx(0.5)
+ assert caps['symbiotic'] == pytest.approx(0.7)
+ assert caps['intelligent'] == pytest.approx(0.6)
+ assert caps['sustainable'] == pytest.approx(0.8)
+
+
+# ---------------------------------------------------------------------------
+# C. Autocatalysis de-saturation
+# ---------------------------------------------------------------------------
+
+def test_autocatalysis_no_longer_saturates_on_modest_cycled_flow():
+ """A network whose cycled-flow fraction is ~15% must NOT pin the flow
+ component of the autocatalytic index to 1.0 (the old `* 10` amplifier
+ saturated anything above 10% cycled flow)."""
+ # Build a network with a dominant acyclic backbone plus a small cycle so the
+ # cycle_flow_ratio lands around 0.15.
+ # Nodes 0->1->2->0 form a cycle; a large through-flow 3->4 dilutes it.
+ fm = np.array([
+ [0, 15, 0, 0, 0],
+ [0, 0, 15, 0, 0],
+ [15, 0, 0, 0, 0],
+ [0, 0, 0, 0, 85],
+ [0, 0, 0, 0, 0],
+ ], dtype=float)
+ oasis = _oasis_for(fm)
+ auto = oasis.calculate_autocatalytic_index()
+
+ ratio = auto['cycle_flow_ratio']
+ # Sanity: the fixture really does have a modest, non-trivial cycled fraction.
+ assert 0.05 < ratio < 0.35, f"fixture cycle_flow_ratio={ratio}"
+
+ # The de-saturated flow component equals the raw ratio (clamped to 1), NOT
+ # min(1, ratio*10) which would be ~1.0 here.
+ flow_component = min(1.0, ratio)
+ assert flow_component < 0.999, "flow component should not be saturated"
+
+ # And the composite index must reflect the un-amplified flow component.
+ # index = 0.5*count_factor + 0.5*flow_component, with flow_component < 1.
+ assert auto['autocatalytic_index'] < 0.5 + 0.5 * 0.999
diff --git a/tests/test_weighting_profiles.py b/tests/test_weighting_profiles.py
new file mode 100644
index 0000000..a42601a
--- /dev/null
+++ b/tests/test_weighting_profiles.py
@@ -0,0 +1,173 @@
+"""
+Tests for named OASIS context WEIGHTING PROFILES.
+
+Rationale: docs/business-revision/evidence/expert-org-management.md ยง3 โ
+keep equal 20% as the published (honest) default, but expose a small number of
+named, context-tagged weighting profiles a consultant selects as a lens. Only
+MODEST tilts (no false precision, no extreme weightings).
+
+Re-weighting is a CHEAP recombination: it only changes the OVERALL score + the
+capped status label, computed as a weighted mean of the FIVE ALREADY-COMPUTED
+dimension scores. It must NOT recompute any dimension metric.
+
+Contract under test (in src/oasis_calculator.py):
+ - WEIGHTING_PROFILES: dict name -> {'weights': {5 dims -> w}, 'description': str}
+ - OASISCalculator.apply_weighting_profile(dimension_scores, profile) ->
+ dict with overall_score + capped status metadata (reuses compute_overall_status).
+"""
+
+import math
+
+import numpy as np
+import pytest
+
+from src.oasis_calculator import OASISCalculator, WEIGHTING_PROFILES
+
+DIMENSIONS = {'open', 'autonomous', 'symbiotic', 'intelligent', 'sustainable'}
+
+
+# --------------------------------------------------------------------------
+# Profile-definition invariants
+# --------------------------------------------------------------------------
+def test_profiles_exist_and_include_balanced_default():
+ assert 'Balanced (default)' in WEIGHTING_PROFILES
+ # 2-4 named profiles per the expert guidance (at least the default + a few).
+ assert 2 <= len(WEIGHTING_PROFILES) <= 6
+
+
+@pytest.mark.parametrize("name", list(WEIGHTING_PROFILES.keys()))
+def test_every_profile_weights_sum_to_one_over_five_dims(name):
+ profile = WEIGHTING_PROFILES[name]
+ weights = profile['weights']
+ # Covers exactly the 5 dimensions.
+ assert set(weights.keys()) == DIMENSIONS, name
+ # Sums to 1.0 within 1e-9.
+ assert abs(sum(weights.values()) - 1.0) < 1e-9, (name, sum(weights.values()))
+
+
+@pytest.mark.parametrize("name", list(WEIGHTING_PROFILES.keys()))
+def test_every_profile_has_human_description(name):
+ desc = WEIGHTING_PROFILES[name].get('description', '')
+ assert isinstance(desc, str) and len(desc.strip()) >= 10, name
+
+
+@pytest.mark.parametrize("name", list(WEIGHTING_PROFILES.keys()))
+def test_tilts_are_modest(name):
+ """No extreme weightings: every weight stays within a modest band of 0.20."""
+ weights = WEIGHTING_PROFILES[name]['weights']
+ for dim, w in weights.items():
+ assert 0.10 <= w <= 0.30, (name, dim, w)
+
+
+def test_balanced_default_is_equal_weights():
+ weights = WEIGHTING_PROFILES['Balanced (default)']['weights']
+ for dim in DIMENSIONS:
+ assert abs(weights[dim] - 0.20) < 1e-9, dim
+
+
+# --------------------------------------------------------------------------
+# Cheap recombination behavior
+# --------------------------------------------------------------------------
+# A non-uniform org (deliberately uneven across dimensions) so a tilt bites.
+NON_UNIFORM = {
+ 'open': 80.0,
+ 'autonomous': 40.0,
+ 'symbiotic': 70.0,
+ 'intelligent': 30.0,
+ 'sustainable': 90.0,
+}
+
+
+def test_balanced_reproduces_equal_weight_overall_exactly():
+ """'Balanced (default)' recombination == the equal-weight compute_overall_status."""
+ baseline = OASISCalculator.compute_overall_status(
+ NON_UNIFORM, OASISCalculator.DEFAULT_WEIGHTS)
+ got = OASISCalculator.apply_weighting_profile(NON_UNIFORM, 'Balanced (default)')
+ assert abs(got['overall_score'] - baseline['overall_score']) < 1e-12
+ assert got['overall_status'] == baseline['overall_status']
+
+
+@pytest.mark.parametrize("name", list(WEIGHTING_PROFILES.keys()))
+def test_recombination_equals_full_compute_for_overall(name):
+ """
+ PARITY: cheap recombination overall == a full OASISCalculator(weights=profile)
+ overall computed from scratch. We drive the calculator through a real flow
+ matrix, read the STORED dimension scores, then check that applying the profile
+ to those stored scores equals building a fresh calculator with those weights.
+ """
+ calc = _make_calculator()
+ profile_scores = calc.get_oasis_profile()['dimension_scores']
+
+ weights = WEIGHTING_PROFILES[name]['weights']
+
+ # Full compute-from-scratch with the profile weights.
+ full = OASISCalculator(_make_ulanowicz(), dimension_weights=weights)
+ full_profile = full.get_oasis_profile()
+
+ # Cheap recombination on the STORED (equal-weight) dimension scores.
+ cheap = OASISCalculator.apply_weighting_profile(profile_scores, name)
+
+ # Dimension scores are identical (weights never touch them), so overalls match.
+ assert abs(cheap['overall_score'] - full_profile['overall_score']) < 1e-9, name
+ assert cheap['overall_status'] == full_profile['overall_status'], name
+
+
+def test_tilted_profile_changes_overall_vs_balanced_on_non_uniform_org():
+ balanced = OASISCalculator.apply_weighting_profile(NON_UNIFORM, 'Balanced (default)')
+ changed = False
+ for name in WEIGHTING_PROFILES:
+ if name == 'Balanced (default)':
+ continue
+ tilted = OASISCalculator.apply_weighting_profile(NON_UNIFORM, name)
+ if abs(tilted['overall_score'] - balanced['overall_score']) > 1e-6:
+ changed = True
+ assert changed, "at least one tilted profile must move the overall on a non-uniform org"
+
+
+def test_capped_status_still_applies_after_reweighting():
+ """A CRITICAL dimension still caps the overall label after re-weighting."""
+ # Four carriers high, sustainable CRITICAL. Even a profile that de-emphasizes
+ # sustainable keeps a high numeric overall but the band cap must hold.
+ scores = {
+ 'open': 100.0, 'autonomous': 100.0, 'symbiotic': 100.0,
+ 'intelligent': 100.0, 'sustainable': 0.0, # critical (<40)
+ }
+ for name in WEIGHTING_PROFILES:
+ res = OASISCalculator.apply_weighting_profile(scores, name)
+ assert res['overall_status'] != 'HEALTHY', name
+ assert res['capped'] is True, name
+ assert 'sustainable' in res['capped_by'], name
+
+
+def test_apply_accepts_explicit_weight_dict():
+ """The method accepts a raw weight dict (manual 'Custom') as well as a name."""
+ custom = {'open': 0.20, 'autonomous': 0.20, 'symbiotic': 0.20,
+ 'intelligent': 0.20, 'sustainable': 0.20}
+ got = OASISCalculator.apply_weighting_profile(NON_UNIFORM, custom)
+ baseline = OASISCalculator.compute_overall_status(NON_UNIFORM)
+ assert abs(got['overall_score'] - baseline['overall_score']) < 1e-12
+
+
+def test_unknown_profile_name_raises():
+ with pytest.raises(ValueError):
+ OASISCalculator.apply_weighting_profile(NON_UNIFORM, 'No Such Profile')
+
+
+# --------------------------------------------------------------------------
+# Helpers: build a real calculator from a small flow matrix.
+# --------------------------------------------------------------------------
+def _make_ulanowicz():
+ from src.ulanowicz_calculator import UlanowiczCalculator
+ # Small asymmetric flow network (non-trivial, non-uniform).
+ flow = np.array([
+ [0.0, 5.0, 2.0, 0.0],
+ [1.0, 0.0, 4.0, 3.0],
+ [0.0, 2.0, 0.0, 6.0],
+ [4.0, 0.0, 1.0, 0.0],
+ ])
+ names = ['A', 'B', 'C', 'D']
+ return UlanowiczCalculator(flow, names)
+
+
+def _make_calculator():
+ return OASISCalculator(_make_ulanowicz())