diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3d3b307..b6e4486 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -27,11 +27,37 @@ jobs:
- name: Install development dependencies
run: python -m pip install --disable-pip-version-check -r requirements-dev.txt
+ - name: Lint (ruff)
+ run: python -m ruff check .
+
- name: Run tests
run: python -m pytest -q
- name: Validate deterministic synthetic data
run: python scripts/validate_demo_data.py
+ - name: Dependency vulnerability scan (advisory)
+ continue-on-error: true
+ run: |
+ python -m pip install pip-audit==2.10.1
+ python -m pip_audit --strict --requirement requirements.txt
+
- name: Build demo container
run: docker build --tag rocc-demo:ci .
+
+ - name: Boot demo container and probe health
+ run: |
+ docker run -d --name rocc-ci -p 8501:8501 rocc-demo:ci
+ ok=0
+ for i in $(seq 1 30); do
+ if curl -fsS http://localhost:8501/_stcore/health >/dev/null 2>&1; then
+ ok=1; break
+ fi
+ sleep 2
+ done
+ docker logs rocc-ci || true
+ docker rm -f rocc-ci || true
+ if [ "$ok" -ne 1 ]; then
+ echo "container did not become healthy within 60s"; exit 1
+ fi
+ echo "container healthy"
diff --git a/.streamlit/config.toml b/.streamlit/config.toml
index c5133ce..faf3ba6 100644
--- a/.streamlit/config.toml
+++ b/.streamlit/config.toml
@@ -7,3 +7,6 @@ font = "sans serif"
[server]
headless = true
+
+[browser]
+gatherUsageStats = false
diff --git a/README.md b/README.md
index 0c25fa1..a347db0 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,11 @@ planning simulation, reports, and the privacy & governance statement.
- Referral-source quality and cold-source tracking against real cadences.
- Mass-push planning when new contracts approach (via a future GovCon Recompete
Radar handoff).
-- Contract DLR trends and ODLH 75%-floor monitoring.
+- Contract direct-labor-ratio (DLR) trends and direct-labor-hours (ODLH) 75%-floor monitoring.
+
+> Acronyms: QDLH = Qualifying Direct Labor Hours, DLH = Direct Labor Hours,
+ratio = QDLH / DLH (the AbilityOne 75% requirement). A full glossary is on the
+Privacy & Governance page.
## Quickstart
diff --git a/app.py b/app.py
index 06b6413..f2ce5d8 100644
--- a/app.py
+++ b/app.py
@@ -61,6 +61,11 @@ def main() -> None:
with st.sidebar:
st.markdown("# ROCC — Recruiting & Outreach Control Center")
st.caption("part of TENS HQ")
+ st.markdown("**Planning controls**")
+ scenario = st.selectbox("Scenario", ["Base", "Conservative", "Optimistic"])
+ target_pct = st.slider("Planning target", 70.0, 82.0, DEFAULT_TARGET * 100.0, 0.5)
+ st.caption("Site indicators are planning proxies. They are not official ODLH determinations.")
+ st.markdown("---")
visible_pages = [page for page in PAGE_RENDERERS if page in allowed_pages()]
_restore_and_clamp_navigation(visible_pages)
page = st.radio(
@@ -74,11 +79,6 @@ def main() -> None:
unsafe_allow_html=True,
)
st.markdown("---")
- st.markdown("**Planning controls**")
- scenario = st.selectbox("Scenario", ["Base", "Conservative", "Optimistic"])
- target_pct = st.slider("Planning target", 70.0, 82.0, DEFAULT_TARGET * 100.0, 0.5)
- st.caption("Site indicators are planning proxies. They are not official ODLH determinations.")
- st.markdown("---")
st.caption(f"Demo v{APP_VERSION} · Seed {DEFAULT_SEED}")
st.query_params["nav"] = page
diff --git a/pyproject.toml b/pyproject.toml
index 7b99e09..01553ef 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,3 +18,10 @@ dev = ["pytest==8.4.1"]
pythonpath = ["src"]
testpaths = ["tests"]
addopts = "-q"
+
+[tool.ruff]
+target-version = "py311"
+src = ["src"]
+
+[tool.ruff.lint]
+select = ["F", "E9"]
diff --git a/requirements-dev.txt b/requirements-dev.txt
index df60fe9..e69def4 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,2 +1,3 @@
-r requirements.txt
pytest==8.4.1
+ruff==0.15.22
diff --git a/run_demo.ps1 b/run_demo.ps1
index 8b81524..1afe7e4 100644
--- a/run_demo.ps1
+++ b/run_demo.ps1
@@ -20,7 +20,7 @@ if (-not (Test-Path -LiteralPath $VenvPython)) {
$BasePython = $PythonCommand.Source
}
& $BasePython -m venv $VenvRoot
- & $VenvPython -m pip install --disable-pip-version-check -r (Join-Path $ProjectRoot 'requirements-dev.txt')
+ & $VenvPython -m pip install --disable-pip-version-check -r (Join-Path $ProjectRoot 'requirements.txt')
}
& $VenvPython -m streamlit run (Join-Path $ProjectRoot 'app.py')
diff --git a/src/tens_hq/constants.py b/src/tens_hq/constants.py
index 733d4c5..700ccb6 100644
--- a/src/tens_hq/constants.py
+++ b/src/tens_hq/constants.py
@@ -214,3 +214,56 @@
"psychological_evaluation",
"disability_narrative",
}
+
+COLUMN_LABELS = {
+ # identity / org
+ "organization_name": "Organization",
+ "organization_type": "Type",
+ "relationship_status": "Relationship",
+ "confidence_level": "Confidence",
+ # geography
+ "county_name": "County",
+ "state_code": "State",
+ "site_name": "Site",
+ # dates / contacts
+ "next_follow_up_date": "Next follow-up",
+ "activity_date": "Activity date",
+ "outreach_type": "Outreach type",
+ "outcome_code": "Outcome",
+ "contact_name": "Contact",
+ "contact_title": "Title",
+ "contact_email": "Email",
+ "preferred_channel": "Preferred channel",
+ # coverage / capability
+ "coverage_strength": "Coverage strength",
+ "verified_status": "Verified",
+ "job_family": "Job family",
+ "capability_level": "Capability level",
+ "evidence_source": "Evidence source",
+ # scores / risk
+ "partner_priority_score": "Partner priority",
+ "reliability_score": "Reliability",
+ "risk_status": "Risk",
+ "days_overdue": "Days overdue",
+ "suggested_next_action": "Suggested next action",
+ # source-performance rates
+ "referral_volume": "Referrals",
+ "hire_yield": "Hire yield %",
+ "eligibility_clearance_yield": "Clearance yield %",
+ "documentation_completion": "Docs complete %",
+ "retention_90": "90-day retention %",
+ # forecast columns
+ "current_ratio": "Current indicator",
+ "projected_ratio": "Projected indicator",
+ "direction": "Change",
+ "expected_attrition_fte": "Expected attrition (FTE)",
+ "pipeline_candidates": "Pipeline candidates",
+ "expected_ready_hires": "Projected pipeline arrivals", # S4 reconciliation
+ "qualified_hiring_need": "Ready hires still needed", # S4 reconciliation
+ "pipeline_coverage": "Coverage (arrivals / need)", # S4 reconciliation
+ "open_roles_count": "Open roles",
+ # privacy inventory
+ "dataset": "Dataset",
+ "rows": "Rows",
+ "synthetic_flag_complete": "All rows synthetic",
+}
diff --git a/src/tens_hq/metrics.py b/src/tens_hq/metrics.py
index f946d3e..4f05bf0 100644
--- a/src/tens_hq/metrics.py
+++ b/src/tens_hq/metrics.py
@@ -174,7 +174,6 @@ def forecast_sites(
if scenario not in _SCENARIO_SETTINGS:
raise ValueError(f"Unknown scenario: {scenario}")
settings = _SCENARIO_SETTINGS[scenario]
- as_of = pd.Timestamp(DATA_AS_OF_DATE)
weeks = horizon_days / 7.0
months = horizon_days / 30.4375
diff --git a/src/tens_hq/pages.py b/src/tens_hq/pages.py
index 1ec0d81..41c2bc2 100644
--- a/src/tens_hq/pages.py
+++ b/src/tens_hq/pages.py
@@ -2,16 +2,15 @@
from __future__ import annotations
-from datetime import date, timedelta
import html
-import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import streamlit as st
from .constants import (
+ COLUMN_LABELS,
DATA_AS_OF_DATE,
DEFAULT_SEED,
DRAFT_BANNER,
@@ -19,7 +18,6 @@
PLANNING_BANNER,
POLICY_FLOOR,
RISK_COLORS,
- RISK_ORDER,
SYNTHETIC_BANNER,
)
from .metrics import (
@@ -118,6 +116,11 @@ def _format_percent_frame(frame: pd.DataFrame, columns: list[str]) -> pd.DataFra
return result
+def _label_columns(frame: pd.DataFrame) -> pd.DataFrame:
+ """Rename raw metric-layer columns to friendly headers for display only."""
+ return frame.rename(columns=COLUMN_LABELS)
+
+
@st.cache_data(show_spinner=False, hash_funcs={DemoData: lambda _: "synthetic-demo-data"})
def _cached_forecast_sites(
data: DemoData,
@@ -166,20 +169,26 @@ def render_home(data: DemoData, target: float, scenario: str) -> None:
impact = st.columns(4)
impact[0].metric(
- "90-day portfolio planning indicator",
+ "90-day planning indicator",
f"{summary['projected_ratio']:.1%}",
delta=f"Target {summary['planning_target']:.1%}",
help="SYN-FORECAST-1.1: sum of projected QDLH divided by sum of projected total DLH.",
)
- gap_direction = "below" if summary["ratio_gap_points"] > 0 else "above"
+ above_target = summary["ratio_gap_points"] < 0 # ratio_gap_points>0 means BELOW target
+ gap_direction = "above" if above_target else "below"
+ surplus_or_shortfall = "surplus" if summary["hours_gap"] < 0 else "shortfall"
impact[1].metric(
"Gap to target",
- f"{abs(summary['ratio_gap_points']):.1f} points {gap_direction}",
- delta=f"{summary['hours_gap']:+,.0f} hours (+ means shortfall)",
- delta_color="inverse",
- help="SYN-FORECAST-1.1: target × summed projected DLH − summed projected QDLH.",
+ f"{abs(summary['ratio_gap_points']):.1f} pts {gap_direction}",
+ delta=f"{abs(summary['hours_gap']):,.0f} hrs {surplus_or_shortfall}",
+ delta_color="off",
+ help="SYN-FORECAST-1.1: target × summed projected DLH − summed projected QDLH. Positive = shortfall (need more qualifying hours); negative = surplus.",
+ )
+ impact[2].metric(
+ "Ready hires needed",
+ summary["qualified_hires_needed"],
+ help="Additional fully-qualifying ready hires to reach the planning target across at-risk sites.",
)
- impact[2].metric("Ready hires needed", summary["qualified_hires_needed"])
impact[3].metric("At Risk / Critical sites", summary["at_risk_sites"])
st.markdown("### Test a leadership commitment")
@@ -275,7 +284,7 @@ def render_home(data: DemoData, target: float, scenario: str) -> None:
y=target,
text="Current course does not cross below target through day 180",
showarrow=False,
- yshift=16,
+ yshift=28,
)
fig.update_layout(title="Portfolio trajectory and live commitment simulation", hovermode="x unified")
fig.update_xaxes(title="Planning horizon (days)", tickvals=list(FORECAST_HORIZONS))
@@ -329,15 +338,23 @@ def render_site_readiness(data: DemoData, target: float, scenario: str) -> None:
row180 = forecasts180.loc[forecasts180["site_name"] == selected_name].iloc[0]
site_id = row90["site_id"]
- st.markdown(f"### {selected_name} {_risk_badge(row90['risk_status'])}", unsafe_allow_html=True)
+ st.markdown(f"### {html.escape(selected_name)} {_risk_badge(row90['risk_status'])}", unsafe_allow_html=True)
cols = st.columns(6)
cols[0].metric("Current indicator", f"{row90['current_ratio']:.1%}")
cols[1].metric("90-day", f"{row90['projected_ratio']:.1%}", f"{row90['direction']:+.1%}")
cols[2].metric("180-day", f"{row180['projected_ratio']:.1%}")
cols[3].metric("Open roles", int(row90["open_roles_count"]))
- cols[4].metric("Estimated ready hires needed", int(row90["qualified_hiring_need"] or 0))
- cols[5].metric("Expected ready hires", f"{row90['expected_ready_hires']:.1f}")
+ cols[4].metric("Ready hires still needed", int(row90["qualified_hiring_need"] or 0))
+ cols[5].metric("Projected pipeline arrivals", f"{row90['expected_ready_hires']:.1f}")
st.markdown(f'
{html.escape(row90["explanation"])}
', unsafe_allow_html=True)
+ _cov = row90["pipeline_coverage"]
+ _cov_txt = "n/a" if pd.isna(_cov) else f"{_cov:.2f}x"
+ st.caption(
+ f"Coverage = projected arrivals / ready hires still needed = {_cov_txt}. "
+ "A site can project more arrivals than it still needs and remain At Risk: "
+ "projected arrivals are already inside the projected indicator, while "
+ "'still needed' is the residual gap after them."
+ )
left, right = st.columns([1.35, 1])
with left:
@@ -396,19 +413,30 @@ def render_site_readiness(data: DemoData, target: float, scenario: str) -> None:
st.info("No scored source covers this fictional county; validate the resource inventory.")
else:
st.dataframe(
- recommended[
- ["organization_name", "relationship_status", "partner_priority_score", "confidence_level"]
- ].round({"partner_priority_score": 1}),
+ _label_columns(
+ recommended[
+ ["organization_name", "relationship_status", "partner_priority_score", "confidence_level"]
+ ].round({"partner_priority_score": 1})
+ ),
hide_index=True,
use_container_width=True,
)
st.markdown("#### Manager action plan")
- st.markdown(
- "1. Confirm the assumption set and latest labor close.\n"
- "2. Assign the top three partner contacts to an owner.\n"
- "3. Complete a 14-day outreach sprint for At Risk/Critical sites.\n"
- "4. Re-run the forecast after the next pipeline review."
+ top_partners = (
+ recommended["organization_name"].head(3).tolist() if not recommended.empty else []
)
+ partners_txt = ", ".join(top_partners) if top_partners else "the highest-priority covered partners"
+ steps = [
+ f"1. Confirm **{selected_name}**'s latest labor close and the {row90['risk_status']} assumption set.",
+ f"2. Assign an owner to contact {partners_txt}.",
+ ]
+ if row90["risk_status"] in {"At Risk", "Critical"}:
+ steps.append("3. Run a 14-day outreach sprint (this site is At Risk/Critical).")
+ steps.append("4. Re-run the forecast after the next pipeline review.")
+ else:
+ steps.append("3. Maintain the standard partner cadence; no sprint required at current risk.")
+ steps.append("4. Re-run the forecast after the next pipeline review.")
+ st.markdown("\n".join(steps))
def render_resource_network(data: DemoData, target: float, scenario: str) -> None:
@@ -438,6 +466,10 @@ def render_resource_network(data: DemoData, target: float, scenario: str) -> Non
due = int((pd.to_datetime(filtered["next_follow_up_date"]) <= pd.Timestamp(DATA_AS_OF_DATE)).sum())
cols[3].metric("Follow-ups due", due)
+ if filtered.empty:
+ st.info("No organizations match these filters. Adjust the State, Organization type, or Relationship filter to see the network.")
+ return
+
left, right = st.columns([1.25, 1])
with left:
counts = filtered.groupby(["organization_type", "relationship_status"], as_index=False).size()
@@ -453,9 +485,11 @@ def render_resource_network(data: DemoData, target: float, scenario: str) -> Non
with right:
st.markdown("#### Filtered directory")
st.dataframe(
- filtered[
- ["organization_name", "organization_type", "county_name", "state_code", "relationship_status", "next_follow_up_date"]
- ],
+ _label_columns(
+ filtered[
+ ["organization_name", "organization_type", "county_name", "state_code", "relationship_status", "next_follow_up_date"]
+ ]
+ ),
hide_index=True,
use_container_width=True,
height=410,
@@ -477,11 +511,11 @@ def render_resource_network(data: DemoData, target: float, scenario: str) -> Non
contacts = data.contacts.loc[data.contacts["organization_id"] == org_id]
tab_names = ["Coverage", "Job families", "Business contacts", "Outreach history"]
tabs = dict(zip(tab_names, st.tabs(tab_names)))
- tabs["Coverage"].dataframe(coverage_detail[["county_name", "state_code", "coverage_strength", "verified_status"]], hide_index=True, use_container_width=True)
- tabs["Job families"].dataframe(capabilities[["job_family", "capability_level", "evidence_source"]], hide_index=True, use_container_width=True)
- tabs["Business contacts"].dataframe(contacts[["contact_name", "contact_title", "contact_email", "preferred_channel"]], hide_index=True, use_container_width=True)
+ tabs["Coverage"].dataframe(_label_columns(coverage_detail[["county_name", "state_code", "coverage_strength", "verified_status"]]), hide_index=True, use_container_width=True)
+ tabs["Job families"].dataframe(_label_columns(capabilities[["job_family", "capability_level", "evidence_source"]]), hide_index=True, use_container_width=True)
+ tabs["Business contacts"].dataframe(_label_columns(contacts[["contact_name", "contact_title", "contact_email", "preferred_channel"]]), hide_index=True, use_container_width=True)
activities = data.outreach.loc[data.outreach["organization_id"] == org_id].sort_values("activity_date", ascending=False)
- tabs["Outreach history"].dataframe(activities[["activity_date", "outreach_type", "outcome_code", "next_follow_up_date"]].head(20), hide_index=True, use_container_width=True)
+ tabs["Outreach history"].dataframe(_label_columns(activities[["activity_date", "outreach_type", "outcome_code", "next_follow_up_date"]].head(20)), hide_index=True, use_container_width=True)
def render_outreach(data: DemoData, target: float, scenario: str) -> None:
@@ -491,6 +525,10 @@ def render_outreach(data: DemoData, target: float, scenario: str) -> None:
"Recruiting / Action Queue",
)
st.markdown(f'{DRAFT_BANNER} · ROCC has no email sending capability.
', unsafe_allow_html=True)
+ st.caption(
+ "This queue is a read-only planning view, recomputed from synthetic aggregates on each load. "
+ "Assigning, completing, or deferring actions is a proposed next slice (ADR-009), not yet built."
+ )
forecasts = _cached_forecast_sites(data, DEFAULT_SEED, target, scenario, 90)
scores = _cached_source_performance(data, DEFAULT_SEED)
queue = build_outreach_queue(data, scores, forecasts)
@@ -515,7 +553,26 @@ def render_outreach(data: DemoData, target: float, scenario: str) -> None:
]
].head(100).copy()
queue_view["partner_priority_score"] = queue_view["partner_priority_score"].round(1)
- st.dataframe(queue_view, hide_index=True, use_container_width=True, height=360)
+ queue_view = _label_columns(queue_view)
+
+ def _overdue_css(column: pd.Series) -> list[str]:
+ out = []
+ for value in column:
+ if value >= 120:
+ out.append("background-color: #F9DEDC; color: #7A271A; font-weight: 700")
+ elif value >= 60:
+ out.append("background-color: #FDECC8; color: #7A4D00")
+ else:
+ out.append("")
+ return out
+
+ styled_queue = (
+ queue_view.style
+ .apply(_overdue_css, subset=["Days overdue"])
+ .format({"Partner priority": "{:.1f}", "Days overdue": "{:.0f}"})
+ )
+ st.dataframe(styled_queue, hide_index=True, use_container_width=True, height=360)
+ st.caption("Rows shaded by how overdue they are: red >= 120 days, amber >= 60 days. The queue is sorted by priority underneath.")
st.markdown("### Draft generator")
draft_cols = st.columns(2)
@@ -539,8 +596,8 @@ def render_outreach(data: DemoData, target: float, scenario: str) -> None:
job_family_name,
selected_queue["relationship_status"],
)
- st.text_input("Draft subject", value=subject)
- st.text_area("Draft message", value=body, height=300)
+ st.text_input("Draft subject", value=subject, disabled=True)
+ st.text_area("Draft message", value=body, height=300, disabled=True)
st.caption("Review through approved organizational channels. The demo intentionally provides no Send button and stores no real contact data.")
with st.expander("Call and voicemail scripts"):
@@ -598,6 +655,15 @@ def render_applicant_pipeline(data: DemoData, target: float, scenario: str) -> N
"Conversion from prior stage"
].map(lambda value: "Entry" if pd.isna(value) else f"{value:.1%}")
st.dataframe(funnel_display, hide_index=True, use_container_width=True)
+ _conv = funnel[["Stage", "Conversion from prior stage"]].dropna(
+ subset=["Conversion from prior stage"]
+ )
+ if not _conv.empty:
+ _weak = _conv.loc[_conv["Conversion from prior stage"].idxmin()]
+ st.warning(
+ f"Weakest conversion: **{_weak['Stage']}** at "
+ f"{_weak['Conversion from prior stage']:.1%} from the prior stage - the primary funnel bottleneck."
+ )
stage_col, source_col = st.columns(2)
with stage_col:
@@ -649,6 +715,7 @@ def render_source_performance(data: DemoData, target: float, scenario: str) -> N
log_x=True,
)
fig.update_yaxes(range=[0, 100])
+ fig.update_xaxes(title="Referral volume (log scale)")
st.plotly_chart(_plot_layout(fig, 500), use_container_width=True)
display = scores[
@@ -667,7 +734,7 @@ def render_source_performance(data: DemoData, target: float, scenario: str) -> N
]
].copy()
display = display.round(1)
- st.dataframe(display, hide_index=True, use_container_width=True, height=350)
+ st.dataframe(_label_columns(display), hide_index=True, use_container_width=True, height=350)
selected = st.selectbox("Open source detail", scores["organization_name"].tolist())
row = scores.loc[scores["organization_name"] == selected].iloc[0]
@@ -709,12 +776,16 @@ def render_ratio_forecast(data: DemoData, target: float, scenario: str) -> None:
forecasts = _cached_forecast_sites(data, DEFAULT_SEED, target, scenario, horizon)
summary = portfolio_summary(forecasts)
- cols = st.columns(5)
+ cols = st.columns(4)
cols[0].metric("Portfolio scenario", f"{summary['projected_ratio']:.1%}")
- cols[1].metric("Current policy floor reference", f"{POLICY_FLOOR:.1%}")
+ cols[1].metric(
+ "Planning floor (internal)",
+ f"{POLICY_FLOOR:.1%}",
+ help="Synthetic internal early-warning floor for this demo - NOT a statutory figure. The AbilityOne requirement is the 75% direct-labor-hours ratio.",
+ )
cols[2].metric("At Risk / Critical", summary["at_risk_sites"])
- cols[3].metric("Additional ready hires", summary["qualified_hires_needed"])
- cols[4].metric("Formula version", forecasts["formula_version"].iloc[0])
+ cols[3].metric("Ready hires still needed", summary["qualified_hires_needed"])
+ st.caption(f"Formula version: {forecasts['formula_version'].iloc[0]} - synthetic planning model")
comparison = forecasts[["site_name", "current_ratio", "projected_ratio", "risk_status"]].melt(
id_vars=["site_name", "risk_status"],
@@ -753,7 +824,12 @@ def render_ratio_forecast(data: DemoData, target: float, scenario: str) -> None:
table = _format_percent_frame(table, ["current_ratio", "projected_ratio", "direction"])
table["expected_ready_hires"] = table["expected_ready_hires"].round(1)
table["pipeline_coverage"] = table["pipeline_coverage"].map(lambda value: "N/A" if pd.isna(value) else f"{value:.2f}")
- st.dataframe(table, hide_index=True, use_container_width=True)
+ st.dataframe(_label_columns(table), hide_index=True, use_container_width=True)
+ st.caption(
+ "'Projected pipeline arrivals' are already included in the projected indicator; "
+ "'Ready hires still needed' is the residual gap after them, so a site can show "
+ "more arrivals than needed and still be At Risk. Coverage = arrivals / need."
+ )
st.markdown("### Scenario sensitivity")
scenario_frames = []
@@ -794,7 +870,11 @@ def render_ratio_forecast(data: DemoData, target: float, scenario: str) -> None:
"- Started people are excluded from expected pipeline hours to prevent actual/expected double counting.\n"
"- Site percentages are never averaged; the portfolio rolls up summed numerator and denominator.\n"
"- A zero denominator displays Not Applicable.\n"
- "- If a hire assumption cannot mathematically reach the target, the app returns an assumption error instead of a number."
+ "- Each additional ready hire is assumed to contribute fully-qualifying hours (Hq = Hd) at this planning stage, "
+ "so 'ready hires still needed' is the residual hours gap / per-hire qualifying hours.\n"
+ "- If a per-hire qualifying assumption could not mathematically reach the target, the need is reported as "
+ "not-reachable rather than a number (a guard retained for a future partial-QDL yield factor; it does not "
+ "trigger under the current full-QDL assumption)."
)
@@ -865,7 +945,8 @@ def render_governance(data: DemoData, target: float, scenario: str) -> None:
left, right = st.columns([1, 1.1])
with left:
st.markdown("### Data inventory")
- st.dataframe(counts, hide_index=True, use_container_width=True)
+ st.dataframe(_label_columns(counts), hide_index=True, use_container_width=True)
+ st.caption("The applicants and stage-history rows exist only as in-memory aggregation inputs; they are never rendered, listed, or scored per person (ADR-024).")
st.markdown("### Non-negotiable commitments")
st.markdown(
"- **SYNTHETIC ONLY UNTIL EMPLOYER SPONSORSHIP.** No real-data pathway ships in this demo.\n"
@@ -901,6 +982,16 @@ def render_governance(data: DemoData, target: float, scenario: str) -> None:
unsafe_allow_html=True,
)
+ with st.expander("Glossary - synthetic planning terms"):
+ st.markdown(
+ "- **DLH** - Direct Labor Hours: hours worked on the contract's direct labor.\n"
+ "- **QDLH** - Qualifying Direct Labor Hours: the subset of DLH performed by qualifying employees.\n"
+ "- **QDL** - Qualifying Direct Labor (the qualifying-employee labor category).\n"
+ "- **Ratio / planning indicator** - QDLH / DLH, rolled up org-wide by summing hours (never by averaging site percentages). The AbilityOne requirement is 75%.\n"
+ "- **FTE** - Full-Time Equivalent, used here only for synthetic attrition assumptions.\n"
+ "- **ODLH / DLR** - internal shorthand in this demo for the AbilityOne direct-labor-hours ratio (the 75% requirement). These are not standard statutory acronyms; the standard phrasing is 'direct labor hours ratio.'"
+ )
+
with st.expander("Current primary-source framing used by this concept"):
st.markdown(
"- [41 U.S.C. § 8501](https://uscode.house.gov/view.xhtml?edition=prelim&num=0&req=granuleid%3AUSC-prelim-title41-section8501)\n"
diff --git a/src/tens_hq/synthetic.py b/src/tens_hq/synthetic.py
index ae1a9e5..4878865 100644
--- a/src/tens_hq/synthetic.py
+++ b/src/tens_hq/synthetic.py
@@ -290,9 +290,6 @@ def generate_demo_data(seed: int = DEFAULT_SEED) -> DemoData:
outreach = pd.DataFrame(outreach_rows)
sites_by_county = sites.groupby("county_id")["site_id"].apply(list).to_dict()
- organization_position_by_id = {
- org_id: position for position, org_id in enumerate(organizations["organization_id"].tolist())
- }
organization_records = organizations.to_dict("records")
site_record_by_id = {record["site_id"]: record for record in sites.to_dict("records")}
applicant_rows: list[dict] = []
diff --git a/src/tens_hq/validation.py b/src/tens_hq/validation.py
index 1db7d6f..8001b80 100644
--- a/src/tens_hq/validation.py
+++ b/src/tens_hq/validation.py
@@ -4,11 +4,25 @@
from dataclasses import dataclass
-import pandas as pd
-
-from .constants import PROHIBITED_COLUMN_TOKENS
+from .constants import COUNTY_NAMES, PROHIBITED_COLUMN_TOKENS, SITE_PROFILES
from .synthetic import DemoData
+_SYNTHETIC_HISTORY_MONTHS = 24 # months of labor history per site (see synthetic.py labor loop, range(24))
+
+
+def expected_row_counts() -> dict[str, int]:
+ """Expected row counts. Derivable counts track the generator constants."""
+ return {
+ "counties": sum(len(names) for names in COUNTY_NAMES.values()),
+ "sites": len(SITE_PROFILES),
+ "organizations": 320,
+ "contacts": 540,
+ "outreach": 1800,
+ "applicants": 1500,
+ "labor_hours": len(SITE_PROFILES) * _SYNTHETIC_HISTORY_MONTHS,
+ "opportunities": 60,
+ }
+
@dataclass(frozen=True)
class ValidationResult:
@@ -23,16 +37,7 @@ def ok(self) -> bool:
def validate_demo_data(data: DemoData) -> ValidationResult:
errors: list[str] = []
warnings: list[str] = []
- expected_counts = {
- "counties": 48,
- "sites": 12,
- "organizations": 320,
- "contacts": 540,
- "outreach": 1800,
- "applicants": 1500,
- "labor_hours": 288,
- "opportunities": 60,
- }
+ expected_counts = expected_row_counts()
for name, expected in expected_counts.items():
actual = len(getattr(data, name))
if actual != expected:
diff --git a/tests/test_labels.py b/tests/test_labels.py
new file mode 100644
index 0000000..1a62661
--- /dev/null
+++ b/tests/test_labels.py
@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+import pandas as pd
+from streamlit.testing.v1 import AppTest
+
+APP_PATH = str(Path(__file__).resolve().parents[1] / "app.py")
+SNAKE = re.compile(r"^[a-z][a-z0-9]*(_[a-z0-9]+)+$")
+
+
+def _columns_on_page(page_key: str) -> set[str]:
+ at = AppTest.from_file(APP_PATH, default_timeout=30)
+ at.session_state["nav"] = page_key
+ at.run()
+ assert not at.exception, f"{page_key} raised: {at.exception}"
+ cols: set[str] = set()
+ for df in at.dataframe:
+ value = df.value
+ # AppTest returns the underlying DataFrame even for a Styler-backed
+ # st.dataframe (verified empirically on streamlit 1.46.1: the Outreach
+ # queue's Styler surfaces as a plain DataFrame with friendly columns).
+ # The .data guard is defensive only and is a no-op on 1.46.1.
+ frame = value.data if hasattr(value, "data") else value
+ if isinstance(frame, pd.DataFrame):
+ cols.update(str(c) for c in frame.columns)
+ return cols
+
+
+def test_leadership_tables_have_no_snake_case_headers():
+ for page in [
+ "Site Readiness",
+ "Resource Network",
+ "Source Performance",
+ "Ratio Forecast",
+ "Privacy & Governance",
+ "Outreach Command Center",
+ ]:
+ offenders = {c for c in _columns_on_page(page) if SNAKE.match(c)}
+ assert not offenders, f"{page} still shows raw headers: {sorted(offenders)}"
diff --git a/tests/test_validation.py b/tests/test_validation.py
new file mode 100644
index 0000000..3955729
--- /dev/null
+++ b/tests/test_validation.py
@@ -0,0 +1,18 @@
+from __future__ import annotations
+
+from tens_hq.constants import COUNTY_NAMES, SITE_PROFILES
+from tens_hq.validation import expected_row_counts, validate_demo_data
+
+
+def test_derivable_counts_track_generator_constants():
+ counts = expected_row_counts()
+ assert counts["sites"] == len(SITE_PROFILES) == 12
+ assert counts["counties"] == sum(len(v) for v in COUNTY_NAMES.values()) == 48
+ assert counts["labor_hours"] == len(SITE_PROFILES) * 24 == 288
+
+
+def test_expected_counts_reproduce_actual_generation(demo_data):
+ counts = expected_row_counts()
+ for name, expected in counts.items():
+ assert len(getattr(demo_data, name)) == expected, name
+ assert validate_demo_data(demo_data).ok