diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f40105..a36a09a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ Documentation tooling now counts prose separately from fenced code and diagrams, The abbreviated license file has been replaced with the official Apache License 2.0 text. Project attribution is recorded separately in NOTICE, and citation metadata identifies the author as an independent Romanian researcher. The existing release tag is preserved. The citation's release version is not silently advanced to an unpublished development version merely because the Python package declares that version. -Analysis-integrity work now includes reproducible time handling for navigation scores, plus the two targeted corrections recorded after the documentation baseline. Pathway enrichment now applies monotonic Benjamini–Hochberg adjustment over the full valid pathway family, including valid pathways with no observed overlap in the correction denominator. Multi-omics grouping now rejects duplicate sample-layer pairs and inconsistent participant mappings instead of silently overwriting earlier values. The frontend now exposes key provenance boundaries and has build-plus-browser smoke coverage, but it still needs broader accessibility, visual regression, and production deployment evidence. Publication origin classification is improved, but provider origin remains a retrieval-boundary label rather than scientific validation. No clinical validation, independent benchmark, or operational scientific review service is asserted by this documentation entry. +Analysis-integrity work now includes reproducible time handling, record-level score metadata, auditable score components, deterministic citation-export manifests, and SHA-256 export fingerprints for navigation scores through both the evidence engine and the evidence API, plus the two targeted corrections recorded after the documentation baseline. Pathway enrichment now applies monotonic Benjamini–Hochberg adjustment over the full valid pathway family, including valid pathways with no observed overlap in the correction denominator. Multi-omics grouping now rejects duplicate sample-layer pairs and inconsistent participant mappings instead of silently overwriting earlier values. The frontend now exposes key provenance boundaries and has build-plus-browser smoke coverage, but it still needs broader accessibility, visual regression, and production deployment evidence. Publication origin classification is improved, but provider origin remains a retrieval-boundary label rather than scientific validation. No clinical validation, independent benchmark, or operational scientific review service is asserted by this documentation entry. ## [0.2.0] - 2026-09-14 diff --git a/README.md b/README.md index a477ce7..98d9200 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Synthetic records are software fixtures only and are not scientific conclusions. ## Scientific specification -The [whitepaper](WHITEPAPER.md) defines the data model, provider contract, scoring equations, validation protocol, trust boundaries, and research roadmap. The [academic manifesto](ACADEMIC_MANIFESTO.md) states the review and ethics commitments behind the implementation. +The [whitepaper](WHITEPAPER.md) defines the data model, provider contract, scoring equations, validation protocol, trust boundaries, and research roadmap. The [academic manifesto](ACADEMIC_MANIFESTO.md) states the review and ethics commitments behind the implementation. The [OpenLongevityLab impact article](docs/research/openlongevity-impact-article.md) explains the repository purpose, current maturity, intended final stage, collaboration path, and donation link in long-form academic prose. ## Repository structure diff --git a/docs/API.md b/docs/API.md index 5feb1c5..b43eaa4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -79,13 +79,13 @@ Evidence, evidence detail, and research-gap routes operate on synthetic fixtures `GET /api/v1/evidence/export/citation` is the first executable export boundary for the fixture corpus. It returns `mode: citation-eligible`, an `items` list, an `excluded` list, totals for both lists, a schema version, and the research disclaimer. Under the current fixture-only evidence mode, `SYN-*` records are excluded with reason `synthetic_fixture`, so the citation-eligible item list is empty for the bundled cellular-senescence demonstration. Non-synthetic records must also carry `review_status: verified` plus reviewer identity, review timestamp, and review notes before they can enter the citation-eligible item list. This is intentional: the route proves that the platform can reject demonstration data and unverified evidence rather than allowing attractive records to leak into citation workflows. -The citation export route should not be described as a complete publication export system. It does not yet produce bibliographic formats, persistent publication manifests, human-review certificates, or provider-backed evidence bundles. It establishes a narrow behavior that was previously documented only as a policy: synthetic fixtures are not observations and are excluded by default from citation-eligible evidence export. Future work can extend the same contract to persisted publication records once review status, source authenticity, and export manifests are implemented for that path. +The citation export route should not be described as a complete publication export system. It does not yet produce bibliographic formats, human-review certificates, or provider-backed evidence bundles. It establishes a narrow behavior that was previously documented only as a policy: synthetic fixtures are not observations and are excluded by default from citation-eligible evidence export. The response now includes a deterministic manifest with included and excluded identifiers, exclusion reasons, scoring metadata, and a SHA-256 `export_fingerprint` so repeated exports can be compared. Future work can extend the same contract to persisted publication records once review status and source authenticity are implemented for that path. `POST /api/v1/evidence/{record_id}/review` records a persistent review event when PostgreSQL is configured and the caller supplies `X-Review-Key` matching `OPENLONGEVITY_REVIEW_KEY`. The request body includes `status`, `reviewer`, `reviewed_at`, and `notes`. The server rejects machine-only statuses as human review actions and requires the same metadata that citation export later expects from verified records. Without a configured review key, the route returns `REVIEW_DISABLED`; without a configured and migrated database, it returns `DATABASE_NOT_CONFIGURED`. This keeps the preview from pretending that review events are persistent when the audit table is not available. `GET /api/v1/evidence/{record_id}/review-events` lists stored review events for a fixture evidence record when the review repository is configured. The route returns audit events, not a full reviewer user interface. It is the persistence boundary for the human-review workflow: reviewer actions can be stored, inspected, and connected to citation-export eligibility, while user management and role delegation remain future work. -Evidence grades and scores require their methodological labels. The A–G mapping is a project taxonomy, and the numerical navigation score uses heuristic constants. Neither is a calibrated scientific certainty estimate. When a publication date is present, the score depends on the scoring time; evidence summaries now expose `scoring_as_of` so clients can record that temporal basis. The API's ability to serialize a number does not justify describing it as a treatment effect, probability of truth, or measure of human longevity benefit. +Evidence grades and scores require their methodological labels. The A–G mapping is a project taxonomy, and the numerical navigation score uses heuristic constants. Neither is a calibrated scientific certainty estimate. When a publication date is present, the score depends on the scoring time; `GET /api/v1/evidence` accepts an optional `scoring_as_of` ISO 8601 datetime query parameter and evidence summaries expose the normalized `scoring_as_of` value so clients can record that temporal basis. Evidence items and detail responses include `navigation_score`, `score_method`, record-level `scoring_as_of`, and `score_components` metadata so exports remain traceable to the scoring contract. The API's ability to serialize a number does not justify describing it as a treatment effect, probability of truth, or measure of human longevity benefit. Publication origin classification is now explicit for persisted publications, including synthetic seed rows and records saved through the PubMed ingestion path. Clients and operators must still avoid treating `synthetic: false` as proof of scientific reliability. It means the record was not classified as synthetic by the storage contract and entered through a provider-boundary path; it does not mean the publication is complete, unretracted, clinically relevant, or human reviewed. diff --git a/docs/README.md b/docs/README.md index c01ddf3..9c8e26a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,7 @@ - `evidence/Evidence-Model.md` — record semantics and grading. - `biomarkers/Biomarkers.md` — measurement categories and limitations. - `research/Research-Gap-Detector.md` — prioritization heuristics. +- `research/openlongevity-impact-article.md` — long-form impact article describing purpose, current stage, final target, collaboration, and donation support. - `security/THREAT_MODEL.md` — threats and mitigations. - `architecture/adr/` — decisions that affect long-term design. - `academic/` — thirty-one academic research notes, with expansion status and reading paths in the collection index. @@ -12,7 +13,7 @@ This directory connects the implementation of OpenLongevity with the explanations needed to inspect it. The project includes a Python research core, provider adapters, publication persistence, an API, experimental analysis utilities, and an early web interface. Those components have different maturity levels. Documentation should help readers identify what is implemented, what has been tested, and what remains a proposed research method. It should not present every topic in the directory as an available product feature. -The root [README](../README.md) introduces current capabilities and installation assumptions. The [whitepaper](../WHITEPAPER.md) provides the main technical and scientific specification, including corrections to earlier examples and maturity claims. The [academic manifesto](../ACADEMIC_MANIFESTO.md) explains the project's standards for evidence and accountability. This index helps readers move from those general documents to focused references without relying on the removed wiki directory. +The root [README](../README.md) introduces current capabilities and installation assumptions. The [whitepaper](../WHITEPAPER.md) provides the main technical and scientific specification, including corrections to earlier examples and maturity claims. The [academic manifesto](../ACADEMIC_MANIFESTO.md) explains the project's standards for evidence and accountability. The [impact article](research/openlongevity-impact-article.md) gives an external-facing academic overview of the repository, its current research-prototype stage, its intended final form, and how collaborators or donors can support the work. This index helps readers move from those general documents to focused references without relying on the removed wiki directory. ## Start from the question you need to answer diff --git a/docs/research/openlongevity-impact-article.md b/docs/research/openlongevity-impact-article.md new file mode 100644 index 0000000..715d328 --- /dev/null +++ b/docs/research/openlongevity-impact-article.md @@ -0,0 +1,60 @@ +# OpenLongevityLab: An Open-Source Computational Repository for Transparent Aging Research, Evidence Mapping, and Reproducible Longevity Science + +**Principal Author:** Ciprian Ștefan Pleșca — Independent Romanian Researcher +**Repository:** [OpenLongevityLab](https://github.com/Ciprian-LocalPulse/OpenLongevityLab) +**Collaboration and Research Support:** [PayPal — agentflowenterprise](https://www.paypal.com/paypalme/agentflowenterprise) + +## Abstract + +OpenLongevityLab is an open-source computational research repository dedicated to the transparent organization, evaluation, and future synthesis of scientific evidence in aging and longevity research. The repository is not a medical product, a clinical decision system, or a claim that any intervention extends human lifespan. Its purpose is more foundational: to create a reproducible digital infrastructure through which scientific publications, evidence records, review status, provenance metadata, biological mechanisms, computational scores, and research gaps can be represented in a form that is inspectable by researchers and contributors. At its current stage, OpenLongevityLab is an advanced research prototype with implemented evidence models, provider-oriented publication infrastructure, synthetic fixture boundaries, API endpoints, documentation audits, scoring transparency, pathway and multi-omics utilities, browser smoke coverage, and academic documentation. Its final intended stage is a governed open-science platform capable of supporting reproducible evidence curation, benchmarked extraction, transparent review workflows, interoperable knowledge graphs, and responsible collaboration across computational biology, geroscience, data engineering, and scientific publishing. This article explains what the repository is for, where it stands now, what it is designed to become, and how collaborators or donors can support the work. + +## 1. Purpose of the Repository + +Aging research is one of the most complex scientific domains of the twenty-first century. It spans molecular biology, genetics, epigenetics, proteomics, metabolomics, inflammation, cellular senescence, mitochondrial dysfunction, clinical biomarkers, cohort studies, animal experiments, randomized trials, computational models, and philosophical questions about healthspan, lifespan, risk, and human responsibility. The volume of literature is too large for informal reading alone, yet the field is too nuanced for simplistic automation. A platform that merely scrapes papers and ranks them with a black-box score would not solve the problem. It could make the problem worse by giving false authority to incomplete summaries. + +OpenLongevityLab exists to address that gap. The repository is designed as transparent infrastructure for aging research, not as a shortcut around scientific judgment. Its central idea is that evidence should be represented with provenance, uncertainty, limitations, review status, and reproducible computational context. A paper is not only a title and abstract. It has a source, retrieval boundary, publication date, possible corrections, study design, species, endpoint, population, confidence, limitations, and interpretation boundary. A computational score is not proof. It is a navigation aid whose components should be visible. A fixture used for software testing is not real scientific evidence. It must be labeled and excluded from citation-eligible export by default. + +The repository therefore supports a disciplined open-science workflow. It helps researchers and developers ask: Where did this record come from? Is it synthetic or provider-derived? Is it reviewed? What scoring method was used? What time basis affected a date-sensitive score? What was excluded from export and why? What assumptions are implemented in code, and which ones remain future research protocols? These questions are essential for a field where overstatement can mislead both scientists and the public. + +## 2. Current Stage of Development + +OpenLongevityLab is currently in a research-prototype stage. This is an important distinction. It is no longer an empty concept or a static README, but it is also not a finished scientific platform. The repository contains real implementation work: Python domain models, evidence grading, navigation scoring, contradiction surfacing, fixture evidence, citation-export boundaries, provider-related publication infrastructure, PostgreSQL-oriented persistence, API routes, documentation tooling, academic notes, frontend smoke tests, and analysis utilities. It also includes explicit disclaimers and methodological boundaries so the repository does not pretend to be a clinical validation system. + +Recent development has strengthened the scientific integrity of the prototype. Pathway enrichment now uses a clearer hypothesis-family contract and monotonic Benjamini-Hochberg adjustment. Multi-omics integration now rejects duplicate sample-layer pairs and inconsistent participant mappings instead of silently overwriting records. Evidence scoring can now be frozen with an explicit `as_of` time, which makes date-sensitive outputs reproducible. Evidence records can carry `navigation_score`, `score_method`, `scoring_as_of`, and auditable `score_components`, allowing a reviewer to reconstruct the score rather than trust a mysterious number. Citation exports now include deterministic manifests and are being extended toward reproducible fingerprints, included and excluded identifiers, exclusion reasons, scoring methods, and temporal scoring context. + +The documentation layer is also unusually important in this repository. OpenLongevityLab includes academic notes, architecture decision records, limitations, governance documents, data-source notes, threat modeling, reproducibility guidance, and audit reports. This is not decorative writing. In a scientific infrastructure project, documentation is part of the evidence contract. If the software changes but the documentation does not, users cannot interpret outputs correctly. If the documentation claims maturity that the code does not support, the repository becomes misleading. The project therefore treats documentation as an active scientific artifact. + +At this stage, however, important limitations remain. The system does not yet provide validated biomedical extraction from full text. It does not establish clinical efficacy. It does not prove that a biomarker, pathway, supplement, drug, behavioral intervention, or molecular mechanism extends human life. It does not replace peer review, systematic review, medical judgment, or regulatory evaluation. Its current value is infrastructural: it builds the computational and conceptual scaffolding needed for more reliable open research. + +## 3. Intended Final Stage + +The final intended stage of OpenLongevityLab is a governed, reproducible, open-source platform for computational longevity science. In that mature form, the repository should support a pipeline from source retrieval to normalized publication records, from evidence extraction to human review, from reviewed records to citation-eligible exports, from individual findings to knowledge graphs, and from knowledge graphs to research-gap detection and hypothesis generation. Every stage should preserve provenance and uncertainty. + +A mature OpenLongevityLab should include benchmarked extraction pipelines. That means curated evaluation corpora, permitted-use datasets, annotation guidelines, inter-reviewer disagreement records, false-positive analysis, and versioned benchmark reports. Claims of extraction quality should come from measured performance, not from optimism. The system should be able to say which task it performs well, which task remains weak, and which inputs are outside scope. + +The platform should also develop stronger review workflows. Machine extraction may propose a candidate evidence record, but verification should remain a human action with reviewer identity, timestamp, notes, and status. A future reviewer interface should allow records to be marked as unreviewed, machine-extracted, human-reviewed, verified, disputed, or rejected according to clear rules. No score should automatically become verification. No attractive dashboard should hide uncertainty. + +Another final-stage goal is an interoperable longevity knowledge graph. Such a graph should connect genes, pathways, hallmarks, biomarkers, interventions, studies, model organisms, outcomes, and limitations. It should not imply causation merely because an edge exists. It should encode relationship types, source records, review status, and confidence boundaries. In its strongest form, the graph could help researchers identify underdeveloped mechanisms, contradictory evidence clusters, translation gaps between animal and human findings, and areas where new experiments or reviews are needed. + +The final stage also requires production-quality operations: deployment evidence, access control, monitoring, backup and recovery procedures, security review, dependency governance, data licensing review, and contributor processes. Open science does not mean uncontrolled science. Responsible openness requires clear boundaries, reproducible releases, and transparent governance. + +## 4. Why This Project Matters + +Longevity research attracts both serious science and exaggerated claims. Public interest is high, commercial incentives are strong, and the literature is technically difficult. Without transparent tools, non-specialists may confuse animal evidence with human evidence, preliminary biomarkers with clinical outcomes, correlation with causation, and software-generated summaries with verified conclusions. Even researchers can struggle to track conflicting findings across disciplines. + +OpenLongevityLab matters because it tries to make the evidence environment more honest. It does not promise immortality. It does not sell a treatment. It does not present a score as truth. Instead, it builds the infrastructure through which claims can be inspected, challenged, corrected, and improved. That is a more modest goal than hype, but it is also more scientifically durable. + +The repository may become useful to computational biologists who need structured evidence representations, software engineers who want to build transparent scientific tools, students learning evidence boundaries, independent researchers building open datasets, and collaborators interested in aging mechanisms. It may also support future educational material, reproducible examples, and public-interest research tools. + +## 5. Collaboration and Support + +OpenLongevityLab is led by **Ciprian Ștefan Pleșca**, an independent Romanian researcher and the principal author of the project. The repository is public at [https://github.com/Ciprian-LocalPulse/OpenLongevityLab](https://github.com/Ciprian-LocalPulse/OpenLongevityLab). Researchers, developers, reviewers, data curators, designers, and open-science supporters are welcome to inspect the repository, propose issues, contribute improvements, review documentation, test workflows, or help develop future evidence pipelines. + +Those who wish to support the research financially may donate through PayPal at [https://www.paypal.com/paypalme/agentflowenterprise](https://www.paypal.com/paypalme/agentflowenterprise). Donations can help sustain development time, infrastructure, documentation, testing, data curation, and future research tooling. Contributions should be understood as support for open research infrastructure, not as purchase of medical advice, clinical services, or guaranteed scientific outcomes. + +## Conclusion + +OpenLongevityLab is an ambitious open-source repository for transparent computational aging research. Its current stage is a serious research prototype with growing technical and academic foundations. Its final goal is a governed, reproducible, evidence-aware platform that helps the longevity field separate source evidence from interpretation, synthetic fixtures from observations, scores from truth, and hypotheses from validated conclusions. If developed responsibly, it can become a valuable open infrastructure layer for scientific collaboration in aging and longevity research. +--- + +**Project author: CIPRIAN ȘTEFAN PLEȘCA — cercetător român independent.** diff --git a/src/openlongevity/api.py b/src/openlongevity/api.py index 3019f8a..4cad042 100644 --- a/src/openlongevity/api.py +++ b/src/openlongevity/api.py @@ -3,6 +3,7 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager from dataclasses import asdict +from datetime import UTC, datetime from os import getenv from typing import Any, Literal @@ -18,7 +19,11 @@ from .constants import DISCLAIMER from .db import Database from .evidence import EvidenceEngine -from .exports import build_citation_export, evidence_record_payload +from .exports import ( + EVIDENCE_SCORE_METHOD_VERSION, + build_citation_export, + evidence_record_payload, +) from .gaps import ResearchGapDetector from .models import EvidenceRecord, ReviewStatus, StudyType from .origins import PublicationOrigin @@ -168,6 +173,23 @@ def require_review_repository() -> EvidenceReviewRepository: "message": "Configure and migrate PostgreSQL first"}) return review_repository + def parse_scoring_as_of(value: str | None) -> datetime | None: + if value is None: + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise HTTPException( + 422, + { + "code": "INVALID_SCORING_AS_OF", + "message": "scoring_as_of must be an ISO 8601 datetime", + }, + ) from exc + if parsed.tzinfo is None: + return parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC) + @app.get("/api/v1/health") async def health() -> dict[str, str]: return {"status": "ok", "version": __version__, @@ -251,29 +273,53 @@ async def current_records(records: list[EvidenceRecord]) -> list[EvidenceRecord] ) if record.identifier in events else record for record in records] @app.get("/api/v1/evidence") - async def evidence(topic: str = Query(default="", max_length=120)) -> dict[str, Any]: + async def evidence( + topic: str = Query(default="", max_length=120), + scoring_as_of: str | None = Query(default=None, max_length=40), + ) -> dict[str, Any]: + scoring_time = parse_scoring_as_of(scoring_as_of) records = await current_records( [r for r in fixtures if topic.casefold() in r.title.casefold()] ) - return {"items": [evidence_record_payload(r, synthetic=True, level=engine.grade(r).value) + summary = engine.summarize(records, as_of=scoring_time) + scoring_time = datetime.fromisoformat(summary["scoring_as_of"]) + return {"items": [evidence_payload(r, synthetic=True, scoring_time=scoring_time) for r in records], "mode": "fixture-only", - "summary": engine.summarize(records), "disclaimer": DISCLAIMER} + "summary": summary, "disclaimer": DISCLAIMER} @app.get("/api/v1/evidence/export/citation") async def citation_export(topic: str = Query(default="", max_length=120)) -> dict[str, Any]: records = await current_records( [r for r in fixtures if topic.casefold() in r.title.casefold()] ) - payloads = [evidence_record_payload(r, synthetic=True, level=engine.grade(r).value) + summary = engine.summarize(records) + scoring_time = datetime.fromisoformat(summary["scoring_as_of"]) + payloads = [evidence_payload(r, synthetic=True, scoring_time=scoring_time) for r in records] - return {**build_citation_export(payloads), "source_mode": "fixture-only"} + return {**build_citation_export(payloads, source_mode="fixture-only"), + "source_mode": "fixture-only"} @app.get("/api/v1/evidence/{identifier}") async def evidence_record(identifier: str) -> dict[str, Any]: record, = await current_records([fixture_by_identifier(identifier)]) - return {"item": evidence_record_payload( - record, synthetic=True, level=engine.grade(record).value, - ), "mode": "fixture-only", "disclaimer": DISCLAIMER} + summary = engine.summarize([record]) + scoring_time = datetime.fromisoformat(summary["scoring_as_of"]) + return {"item": evidence_payload(record, synthetic=True, scoring_time=scoring_time), + "mode": "fixture-only", "summary": summary, "disclaimer": DISCLAIMER} + + + def evidence_payload( + record: EvidenceRecord, *, synthetic: bool, scoring_time: datetime + ) -> dict[str, Any]: + return evidence_record_payload( + record, + synthetic=synthetic, + level=engine.grade(record).value, + navigation_score=engine.score(record, as_of=scoring_time), + score_method=EVIDENCE_SCORE_METHOD_VERSION, + scoring_as_of=scoring_time.isoformat(), + score_components=engine.score_components(record, as_of=scoring_time), + ) def fixture_by_identifier(identifier: str) -> EvidenceRecord: for record in fixtures: @@ -304,9 +350,9 @@ async def review_evidence_record( ) except ValueError as exc: raise HTTPException(422, {"code": "INVALID_REVIEW", "message": str(exc)}) from exc - payload = evidence_record_payload( - reviewed, synthetic=True, level=engine.grade(reviewed).value - ) + summary = engine.summarize([reviewed]) + scoring_time = datetime.fromisoformat(summary["scoring_as_of"]) + payload = evidence_payload(reviewed, synthetic=True, scoring_time=scoring_time) event = await require_review_repository().record_event( record_identifier=reviewed.identifier, status=reviewed.review_status.value, diff --git a/src/openlongevity/evidence.py b/src/openlongevity/evidence.py index 0ff4fc2..aa701a4 100644 --- a/src/openlongevity/evidence.py +++ b/src/openlongevity/evidence.py @@ -64,33 +64,71 @@ def grade(self, record: EvidenceRecord) -> EvidenceLevel: return EvidenceLevel.G return _LEVEL_BY_TYPE[record.study_type] - def score(self, record: EvidenceRecord, *, as_of: datetime | None = None) -> float: - """Return a transparent navigation score, not a validated effect estimate. - - ``as_of`` freezes the publication-age component for reproducible reports. When - omitted, the current UTC time preserves the historical runtime behavior. - """ + def score_components( + self, record: EvidenceRecord, *, as_of: datetime | None = None + ) -> dict[str, float | str | None]: + """Return auditable navigation-score components for one evidence record.""" scoring_time = _utc_datetime(as_of) if as_of else datetime.now(UTC) - score = _BASE_SCORE[record.study_type] * record.confidence - if record.replication_status.casefold() in {"replicated", "independent"}: - score *= 1.15 - elif record.replication_status.casefold() in {"unreplicated", "unknown"}: - score *= 0.85 + base_score = _BASE_SCORE[record.study_type] + replication_status = record.replication_status.casefold() + replication_multiplier = 1.0 + if replication_status in {"replicated", "independent"}: + replication_multiplier = 1.15 + elif replication_status in {"unreplicated", "unknown"}: + replication_multiplier = 0.85 + + sample_size_multiplier = 1.0 if record.sample_size is not None: - score *= min(1.15, 0.85 + (record.sample_size / (record.sample_size + 200))) - if record.retraction_status is RetractionStatus.RETRACTED: - return 0.0 + sample_size_multiplier = min( + 1.15, 0.85 + (record.sample_size / (record.sample_size + 200)) + ) + + publication_age_years: float | None = None + publication_age_multiplier = 1.0 if record.publication_date: try: - age_years = max( + publication_age_years = max( 0.0, (scoring_time - _parse_publication_datetime(record.publication_date)).days / 365.25, ) - score *= max(0.75, 1.0 - age_years * 0.01) + publication_age_multiplier = max(0.75, 1.0 - publication_age_years * 0.01) except ValueError: - pass - return round(min(1.0, max(0.0, score)), 4) + publication_age_years = None + + retraction_multiplier = ( + 0.0 if record.retraction_status is RetractionStatus.RETRACTED else 1.0 + ) + raw_score = ( + base_score + * record.confidence + * replication_multiplier + * sample_size_multiplier + * publication_age_multiplier + * retraction_multiplier + ) + bounded_score = round(min(1.0, max(0.0, raw_score)), 4) + return { + "method": "navigation-score-v1", + "base_score": base_score, + "confidence": record.confidence, + "replication_multiplier": replication_multiplier, + "sample_size_multiplier": round(sample_size_multiplier, 6), + "publication_age_years": round(publication_age_years, 6) + if publication_age_years is not None + else None, + "publication_age_multiplier": round(publication_age_multiplier, 6), + "retraction_multiplier": retraction_multiplier, + "bounded_score": bounded_score, + } + + def score(self, record: EvidenceRecord, *, as_of: datetime | None = None) -> float: + """Return a transparent navigation score, not a validated effect estimate. + + ``as_of`` freezes the publication-age component for reproducible reports. When + omitted, the current UTC time preserves the historical runtime behavior. + """ + return float(self.score_components(record, as_of=as_of)["bounded_score"]) def summarize( self, records: Iterable[EvidenceRecord], *, as_of: datetime | None = None diff --git a/src/openlongevity/exports.py b/src/openlongevity/exports.py index e4b925a..cf7060b 100644 --- a/src/openlongevity/exports.py +++ b/src/openlongevity/exports.py @@ -1,5 +1,8 @@ """Export helpers that preserve the fixture/observation boundary.""" +import hashlib +import json +from collections import Counter from collections.abc import Mapping from dataclasses import asdict from typing import Any @@ -8,13 +11,34 @@ from .models import EvidenceRecord, ReviewStatus CITATION_EXPORT_SCHEMA_VERSION = "citation-export-v1" +EVIDENCE_SCORE_METHOD_VERSION = "navigation-score-v1" + + +def _stable_fingerprint(payload: Mapping[str, Any]) -> str: + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + def evidence_record_payload( - record: EvidenceRecord, *, synthetic: bool, level: str + record: EvidenceRecord, + *, + synthetic: bool, + level: str, + navigation_score: float | None = None, + score_method: str | None = None, + scoring_as_of: str | None = None, + score_components: Mapping[str, Any] | None = None, ) -> dict[str, Any]: """Serialize an evidence record with export-relevant boundary fields.""" - return {**asdict(record), "synthetic": synthetic, "level": level} + payload = {**asdict(record), "synthetic": synthetic, "level": level} + if navigation_score is not None: + payload["navigation_score"] = navigation_score + payload["score_method"] = score_method or EVIDENCE_SCORE_METHOD_VERSION + payload["scoring_as_of"] = scoring_as_of + if score_components is not None: + payload["score_components"] = dict(score_components) + return payload def is_synthetic_record(record: Mapping[str, Any]) -> bool: @@ -41,7 +65,9 @@ def is_human_verified_record(record: Mapping[str, Any]) -> bool: ) -def build_citation_export(records: list[Mapping[str, Any]]) -> dict[str, Any]: +def build_citation_export( + records: list[Mapping[str, Any]], *, source_mode: str = "unspecified" +) -> dict[str, Any]: """Build a citation-eligible export that excludes synthetic fixtures by default.""" included: list[Mapping[str, Any]] = [] excluded: list[dict[str, str]] = [] @@ -63,9 +89,31 @@ def build_citation_export(records: list[Mapping[str, Any]]) -> dict[str, Any]: }) continue included.append(record) + + exclusion_reasons = Counter(item["reason"] for item in excluded) + score_methods = sorted( + {str(record["score_method"]) for record in records if record.get("score_method")} + ) + scoring_times = sorted( + {str(record["scoring_as_of"]) for record in records if record.get("scoring_as_of")} + ) + manifest = { + "schema_version": CITATION_EXPORT_SCHEMA_VERSION, + "source_mode": source_mode, + "input_records": len(records), + "included_records": len(included), + "excluded_records": len(excluded), + "included_identifiers": [str(record.get("identifier", "")) for record in included], + "excluded_identifiers": [item["identifier"] for item in excluded], + "exclusion_reasons": dict(sorted(exclusion_reasons.items())), + "score_methods": score_methods, + "scoring_as_of": scoring_times, + } + manifest["export_fingerprint"] = _stable_fingerprint(manifest) return { "schema_version": CITATION_EXPORT_SCHEMA_VERSION, "mode": "citation-eligible", + "manifest": manifest, "items": included, "excluded": excluded, "total": len(included), diff --git a/tests/test_api.py b/tests/test_api.py index 86969ab..68e4463 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -19,6 +19,9 @@ def test_evidence_without_database_retains_unreviewed_fixture( with TestClient(create_app()) as client: detail = client.get("/api/v1/evidence/SYN-001").json()["item"] listed = client.get("/api/v1/evidence").json()["items"][0] + for volatile_field in ("navigation_score", "score_method", "scoring_as_of"): + detail.pop(volatile_field, None) + listed.pop(volatile_field, None) assert detail == listed assert detail["review_status"] == "unreviewed" assert detail["synthetic"] is True @@ -71,12 +74,59 @@ def test_search_contract() -> None: assert search.json()["total"] >= 1 +def test_evidence_summary_accepts_explicit_scoring_time() -> None: + client = TestClient(create_app()) + response = client.get( + "/api/v1/evidence", + params={"topic": "senescence", "scoring_as_of": "2021-01-01T00:00:00Z"}, + ) + + assert response.status_code == 200 + assert response.json()["summary"]["scoring_as_of"] == "2021-01-01T00:00:00+00:00" + + +def test_evidence_summary_rejects_invalid_scoring_time() -> None: + client = TestClient(create_app()) + response = client.get( + "/api/v1/evidence", + params={"topic": "senescence", "scoring_as_of": "not-a-date"}, + ) + + assert response.status_code == 422 + assert response.json()["error"]["code"] == "INVALID_SCORING_AS_OF" + + def test_missing_evidence_is_structured() -> None: client = TestClient(create_app()) payload = client.get("/api/v1/evidence/unknown").json() assert payload["error"]["code"] == "NOT_FOUND" +def test_evidence_items_include_navigation_score_metadata() -> None: + client = TestClient(create_app()) + payload = client.get( + "/api/v1/evidence", + params={"topic": "senescence", "scoring_as_of": "2021-01-01T00:00:00Z"}, + ).json() + + item = payload["items"][0] + assert item["navigation_score"] == payload["summary"]["mean_navigation_score"] + assert item["score_method"] == "navigation-score-v1" + assert item["scoring_as_of"] == "2021-01-01T00:00:00+00:00" + assert item["score_components"]["bounded_score"] == item["navigation_score"] + assert item["score_components"]["method"] == "navigation-score-v1" + + +def test_evidence_detail_includes_navigation_score_metadata() -> None: + client = TestClient(create_app()) + response = client.get("/api/v1/evidence/SYN-001") + + assert response.status_code == 200 + payload = response.json() + assert payload["item"]["score_method"] == "navigation-score-v1" + assert payload["item"]["scoring_as_of"] == payload["summary"]["scoring_as_of"] + + def test_citation_export_excludes_synthetic_fixtures() -> None: client = TestClient(create_app()) response = client.get("/api/v1/evidence/export/citation", params={"topic": "senescence"}) @@ -84,6 +134,12 @@ def test_citation_export_excludes_synthetic_fixtures() -> None: payload = response.json() assert payload["mode"] == "citation-eligible" assert payload["source_mode"] == "fixture-only" + assert payload["manifest"]["source_mode"] == "fixture-only" + assert payload["manifest"]["input_records"] == 1 + assert payload["manifest"]["exclusion_reasons"] == {"synthetic_fixture": 1} + assert payload["manifest"]["score_methods"] == ["navigation-score-v1"] + assert payload["manifest"]["excluded_identifiers"] == ["SYN-001"] + assert len(payload["manifest"]["export_fingerprint"]) == 64 assert payload["items"] == [] assert payload["total"] == 0 assert payload["excluded_total"] == 1 diff --git a/tests/test_core.py b/tests/test_core.py index 69ce346..8bfda85 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -50,6 +50,25 @@ def test_navigation_score_accepts_explicit_scoring_time() -> None: assert summary["mean_navigation_score"] == engine.score(dated, as_of=early) +def test_navigation_score_components_reconstruct_score() -> None: + engine = EvidenceEngine() + dated = record( + "dated-components", + StudyType.RCT, + publication_date="2020-01-01", + replication_status="replicated", + sample_size=200, + ) + as_of = datetime(2021, 1, 1, tzinfo=UTC) + + components = engine.score_components(dated, as_of=as_of) + + assert components["method"] == "navigation-score-v1" + assert components["base_score"] == 0.85 + assert components["replication_multiplier"] == 1.15 + assert components["bounded_score"] == engine.score(dated, as_of=as_of) + + def test_gap_detector_flags_translation_gap() -> None: gaps = ResearchGapDetector().detect("senescence", [record("a", StudyType.ANIMAL)]) assert gaps[0].kind == "translational_gap" diff --git a/tests/test_exports.py b/tests/test_exports.py index 398b0c8..065ecd4 100644 --- a/tests/test_exports.py +++ b/tests/test_exports.py @@ -43,3 +43,59 @@ def test_citation_export_excludes_synthetic_before_review_check() -> None: assert payload["items"] == [] assert payload["excluded_total"] == 1 assert payload["excluded"][0]["reason"] == "synthetic_fixture" + + +def test_citation_export_manifest_summarizes_boundary_and_scoring() -> None: + synthetic = evidence_record_payload( + record("SYN-TEST"), + synthetic=True, + level="F", + navigation_score=0.12, + score_method="navigation-score-v1", + scoring_as_of="2021-01-01T00:00:00+00:00", + ) + + payload = build_citation_export([synthetic], source_mode="fixture-only") + + assert payload["manifest"] == { + "schema_version": "citation-export-v1", + "source_mode": "fixture-only", + "input_records": 1, + "included_records": 0, + "excluded_records": 1, + "included_identifiers": [], + "excluded_identifiers": ["SYN-TEST"], + "exclusion_reasons": {"synthetic_fixture": 1}, + "score_methods": ["navigation-score-v1"], + "scoring_as_of": ["2021-01-01T00:00:00+00:00"], + "export_fingerprint": payload["manifest"]["export_fingerprint"], + } + assert len(payload["manifest"]["export_fingerprint"]) == 64 + + +def test_citation_export_fingerprint_changes_with_export_boundary() -> None: + unverified = evidence_record_payload( + record("REAL-UNVERIFIED"), + synthetic=False, + level="D", + navigation_score=0.4, + score_method="navigation-score-v1", + scoring_as_of="2021-01-01T00:00:00+00:00", + ) + synthetic = evidence_record_payload( + record("SYN-TEST"), + synthetic=True, + level="F", + navigation_score=0.12, + score_method="navigation-score-v1", + scoring_as_of="2021-01-01T00:00:00+00:00", + ) + + first = build_citation_export([synthetic], source_mode="fixture-only") + second = build_citation_export([synthetic, unverified], source_mode="fixture-only") + + assert first["manifest"]["export_fingerprint"] != second["manifest"]["export_fingerprint"] + assert second["manifest"]["exclusion_reasons"] == { + "not_human_verified": 1, + "synthetic_fixture": 1, + }