From 2507cdd39ed8fd47bf89c52d7b9f17096d2d7e55 Mon Sep 17 00:00:00 2001 From: hamid-arian Date: Wed, 26 Aug 2026 13:28:06 -0400 Subject: [PATCH] Add causal-factor research methods for 1.1.0 --- .github/workflows/CI.yml | 4 + CHANGELOG.md | 57 +- PACKAGE_FILES.json | 1021 ++++++++++++++++- PACKAGE_INSPECTION.md | 42 +- PARITY.md | 55 +- PUBLIC_API.json | 166 ++- Project.toml | 4 +- README.md | 25 +- RELEASE_NOTES_NEXT_MAJOR.md | 60 +- TEST_INVENTORY.json | 63 +- docs/causal_factor_analysis.md | 42 +- docs/compatibility.md | 50 +- docs/make.jl | 9 +- docs/src/index.md | 10 +- examples/causal_factor_analysis_quickstart.jl | 24 +- src/Backtest/BacktestStatistics.jl | 6 +- src/Backtest/MultipleTesting.jl | 6 +- src/Backtest/OUTradingRules.jl | 6 +- src/Backtest/ProbabilisticSharpeRatio.jl | 4 +- .../AllocationDiagnostics.jl | 256 +++++ .../CausalFactorAnalysis.jl | 36 +- src/CausalFactorAnalysis/FactorMirage.jl | 80 ++ .../FalseDiscoveryRates.jl | 584 ++++++++++ src/CausalFactorAnalysis/GraphRoles.jl | 160 +++ src/CausalFactorAnalysis/StructuralModels.jl | 185 +++ src/Data/Denoise/Denoising.jl | 8 +- src/Data/Differentiation/Differentiation.jl | 8 +- src/Data/Distance/DistanceMetric.jl | 8 +- src/Features/EntropyFeatures.jl | 8 +- src/Features/FeatureImportance.jl | 6 +- src/Features/MicrostructuralFeatures.jl | 6 +- src/Features/StructuralBreaks.jl | 18 +- src/Validation/HyperParameterTuning.jl | 12 +- src/Validation/PathAdaptiveCPCV.jl | 6 +- src/Validation/PathBaggedCPCV.jl | 7 +- .../fixtures/additive_numeric_parity.tsv | 14 + .../test_additive_numeric_parity.jl | 145 +++ .../test_allocation_diagnostics.jl | 321 ++++++ .../test_false_discovery_rates.jl | 260 +++++ .../test_generalized_factor_mirage.jl | 242 ++++ .../test_graph_roles.jl | 174 +++ .../test_structural_models.jl | 203 ++++ test/preserved_runtests.jl | 6 +- test/runtests.jl | 39 +- 44 files changed, 4191 insertions(+), 255 deletions(-) create mode 100644 src/CausalFactorAnalysis/AllocationDiagnostics.jl create mode 100644 src/CausalFactorAnalysis/FalseDiscoveryRates.jl create mode 100644 src/CausalFactorAnalysis/GraphRoles.jl create mode 100644 src/CausalFactorAnalysis/StructuralModels.jl create mode 100644 test/causal_factor_analysis/fixtures/additive_numeric_parity.tsv create mode 100644 test/causal_factor_analysis/test_additive_numeric_parity.jl create mode 100644 test/causal_factor_analysis/test_allocation_diagnostics.jl create mode 100644 test/causal_factor_analysis/test_false_discovery_rates.jl create mode 100644 test/causal_factor_analysis/test_generalized_factor_mirage.jl create mode 100644 test/causal_factor_analysis/test_graph_roles.jl create mode 100644 test/causal_factor_analysis/test_structural_models.jl diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 3648a7f..8df0619 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -54,6 +54,7 @@ jobs: Pkg.PackageSpec(name = "DecisionTree", version = v"0.12.4"), Pkg.PackageSpec(name = "Distributions", version = v"0.25.128"), Pkg.PackageSpec(name = "HypothesisTests", version = v"0.11.8"), + Pkg.PackageSpec(name = "QuadGK", version = v"2.11.3"), Pkg.PackageSpec(name = "SpecialFunctions", version = v"2.8.0"), ]) else @@ -64,6 +65,7 @@ jobs: Pkg.PackageSpec(name = "DecisionTree"), Pkg.PackageSpec(name = "Distributions"), Pkg.PackageSpec(name = "HypothesisTests"), + Pkg.PackageSpec(name = "QuadGK"), Pkg.PackageSpec(name = "SpecialFunctions"), ]) end @@ -109,6 +111,7 @@ jobs: Pkg.PackageSpec(name = "DecisionTree", version = v"0.12.4"), Pkg.PackageSpec(name = "Distributions", version = v"0.25.128"), Pkg.PackageSpec(name = "HypothesisTests", version = v"0.11.8"), + Pkg.PackageSpec(name = "QuadGK", version = v"2.11.3"), Pkg.PackageSpec(name = "SpecialFunctions", version = v"2.8.0"), Pkg.PackageSpec(name = "Lux", version = v"1.31.4"), Pkg.PackageSpec(name = "Optimisers", version = v"0.4.7"), @@ -122,6 +125,7 @@ jobs: Pkg.PackageSpec(name = "DecisionTree"), Pkg.PackageSpec(name = "Distributions"), Pkg.PackageSpec(name = "HypothesisTests"), + Pkg.PackageSpec(name = "QuadGK"), Pkg.PackageSpec(name = "SpecialFunctions"), Pkg.PackageSpec(name = "Lux"), Pkg.PackageSpec(name = "Optimisers"), diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bb511c..201f775 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,38 +5,61 @@ All notable changes to `RiskLabAI.jl` are documented here. The format is based o [Semantic Versioning](https://semver.org/spec/v2.0.0.html) (pre-1.0: minor versions may include breaking changes). -## [Unreleased] - -### Added — Stage-1 mop-up: NERCOME, volatility-robust SADF, PELT (wave 29) - -Closes the last Stage-1 Julia-parity gap (the three admits that were implemented in +## [1.1.0] — release candidate + +### Added — causal-factor completion + +- Preserved the released 57-name `RiskLabAI.CausalFactorAnalysis` contract and + added 30 paper-derived names, producing an 87-name causal API with matching + Python concepts. +- Added general-variance factor-mirage coefficients, + allocation-misspecification diagnostics, accepted-DAG factor roles, + deterministic structural-model evaluation, and separate family- and + selection-level false-discovery analytics for searched trials. +- Added direct analytical examples, independent mathematical and graph oracles, + randomized and stability checks, validation boundaries, and a shared + numerical Julia-Python fixture. +- Added QuadGK as a direct dependency with compatibility `>=2.11.3,<3` for the + selected-winner improper integral. Lux, Optimisers, and Zygote + remain isolated behind the Deep-BSDE package extension. + +### Documentation + +- Classified every relevant causal-factor source found in the systematic + library review. Thirteen contradictory or under-specified method units remain + explicitly source-blocked rather than being approximated or guessed. +- Prepared the additive version 1.1.0 candidate while retaining human control + of version control, registration, publication, and release actions. + +### Added — analytical-method completion: NERCOME, volatility-robust SADF, PELT + +Closes the last Julia parity gap (the three admits that were implemented in `RiskLabAI.py` but not yet in Julia). Each docstring carries its preferred-when / -avoid-when regime tag verbatim from the admitting verdict, plus a citation and an -appraisal back-link; parity `@testset`s are in `test/runtests.jl`. +avoid-when usage guidance, plus a citation and a validation summary; parity +`@testset`s are in `test/runtests.jl`. - **Data** - `nercome_denoised_covariance` — NERCOME nonparametric eigenvalue-regularized - covariance (Lam 2016), `Data/Denoise/Denoising.jl`. Admitted in Appraisal 24; + covariance (Lam 2016), `Data/Denoise/Denoising.jl`. Included after independent validation; prefer it over MP clipping on no-gap / non-stationary spectra. Behavioural parity (seeded sample-splitting); validated structurally and on the covariance-accuracy / min-variance mechanism. - **Features** - `volatility_robust_sadf` — volatility-robust SADF/GSADF via wild-bootstrap - critical values (Harvey et al. 2016), `Features/StructuralBreaks.jl`. Admitted in - Appraisal 26. Observed sup-ADF statistics are exact; bootstrap p-values + critical values (Harvey et al. 2016), `Features/StructuralBreaks.jl`. Included after independent validation. Observed sup-ADF statistics are exact; bootstrap p-values behavioural (Julia RNG). - `pelt_change_points` — PELT exact multiple change-point detection (Killick et al. - 2012), `Features/StructuralBreaks.jl`. Admitted in Appraisal 26. Clean-room port + 2012), `Features/StructuralBreaks.jl`. Included after independent validation. Clean-room port of the BSD-2 `ruptures` `Pelt`/`CostNormal`, reproducing its indices exactly. -### Added — Stage-1 parity port (the admitted library-extension methods) +### Added — additional analytical-method parity (the admitted additional analytical work methods) -Ports the admitted Stage-1 methods from `RiskLabAI.py` back to parity in Julia. +Ports the additional analytical methods from `RiskLabAI.py` back to parity in Julia. Each deterministic estimator reproduces the Python reference values within a recorded tolerance (parity `@testset`s in `test/runtests.jl`); stochastic / ADF-dependent pieces are validated structurally. Every method's docstring carries -its preferred-when / avoid-when regime tag verbatim from `CONTRIBUTIONS_LEDGER.md` -and a citation + admitting-appraisal back-link. +its preferred-when / avoid-when usage guidance +and a citation + validation summary. - **Features** - `edge_estimator` — EDGE bid-ask spread estimator (Ardia–Guidotti–Kroencke @@ -89,8 +112,8 @@ and a citation + admitting-appraisal back-link. optional analogues not bundled in the Julia port; both functions fall back to the always-available path (R/S Hurst, random sampling), matching the Python optional-dependency-absent behaviour. See `PARITY.md` for the full divergence list. -- The Stage-1 follow-ups NERCOME, volatility-robust SADF and PELT are now ported (see - the wave-29 entry at the top of this section); the Stage-1 Julia-parity gap is closed. +- The additional methods NERCOME, volatility-robust SADF and PELT are now ported (see + the entry at the top of this section); the Julia parity gap is closed. ## [0.6.1] — 2026-06-20 diff --git a/PACKAGE_FILES.json b/PACKAGE_FILES.json index 1556c96..40f5808 100644 --- a/PACKAGE_FILES.json +++ b/PACKAGE_FILES.json @@ -1,26 +1,86 @@ { - "schema_version": 1, + "schema_version": 2, "document_type": "RISKLABAI_JULIA_INTENDED_PACKAGE_FILES", "product": "RiskLabAI", "language": "Julia", - "version": "1.0.0", + "version": "1.1.0", + "version_note": "Local 1.1.0 candidate metadata is frozen; version control, registration, publication, and release remain human-controlled.", "artifact_types": [ "julia_package_tree" ], - "candidate_file_count": 89, + "candidate_file_count": 159, "candidate_files": [ ".github/workflows/CI.yml", + ".github/workflows/CompatHelper.yml", + ".github/workflows/TagBot.yml", + ".gitignore", + "CHANGELOG.md", "LICENSE", + "LICENSE.txt", "PACKAGE_FILES.json", - "PACKAGE_IDENTITY.json", "PACKAGE_INSPECTION.md", + "PARITY.md", "PUBLIC_API.json", "Project.toml", "README.md", + "RECONSTRUCTION_PLAN.md", "RELEASE_NOTES_NEXT_MAJOR.md", "TEST_INVENTORY.json", + "_superseded/README.md", + "_superseded/src/Backtests/BacktestStatistics.jl", + "_superseded/src/Backtests/BacktestSyntheticData.jl", + "_superseded/src/Backtests/StrategyRisk.jl", + "_superseded/src/Backtests/TestSetOverfitting.jl", + "_superseded/src/BetSize/MixtureModel.jl", + "_superseded/src/Calibration/HyperParameterTuning.jl", + "_superseded/src/Controller/all.jl", + "_superseded/src/Controller/bars_initializer.jl", + "_superseded/src/Controller/data_structure_controller.jl", + "_superseded/src/Data/Structures/DataStructuresLopez.jl", + "_superseded/src/Differentiation/Differentiation.jl", + "_superseded/src/Ensemble/BaggingClassifierAccuracy.jl", + "_superseded/src/Features/BekkerParkinsonVolatilityEstimator.jl", + "_superseded/src/Features/ClusteredMeanDecreaseAccuracy.jl", + "_superseded/src/Features/ClusteredMeanDecreaseImpurity.jl", + "_superseded/src/Features/Clustering.jl", + "_superseded/src/Features/CorwinSchultz.jl", + "_superseded/src/Features/DistanceMetric.jl", + "_superseded/src/Features/Entropy.jl", + "_superseded/src/Features/GenerateSyntheticData.jl", + "_superseded/src/Features/MeanDecreaseAccuracy.jl", + "_superseded/src/Features/MeanDecreaseImpurity.jl", + "_superseded/src/Features/OrthogonalFeatures.jl", + "_superseded/src/Features/SingleFeatureImportance.jl", + "_superseded/src/Features/StructuralBreaks/StructuralBreaks.jl", + "_superseded/src/Features/WeightedTau.jl", + "_superseded/src/HPC/HPC.jl", + "_superseded/src/Models/HyperParameterTuning.jl", + "_superseded/src/Optimization/DistanceFromCorrelation.jl", + "_superseded/src/Optimization/HRP.jl", + "_superseded/src/Optimization/NCO.jl", + "_superseded/src/Optimization/QuasiDiagonal.jl", + "_superseded/src/Risk/StrategyRisk.jl", + "_superseded/src/Utils/RollingFunctions.jl", + "_superseded/src/Utils/VectorFunctions.jl", + "_superseded/src/Validation/BacktestPathNumber.jl", + "_superseded/src/Validation/CalculateEmbargoTimes.jl", + "_superseded/src/Validation/CrossValidationScore.jl", + "_superseded/src/Validation/CrossValidationScore2.jl", + "_superseded/src/Validation/PurgedKFold.jl", + "_superseded/src/Validation/PurgedKFoldCombinatorial.jl", + "_superseded/src/Validation/PurgedKFoldCombinatorialSplit.jl", + "_superseded/src/Validation/PurgedKFoldCombinatorialSplit2.jl", + "_superseded/src/Validation/PurgedKFoldCombinatorialStacked.jl", + "_superseded/src/Validation/PurgedKFoldSplit.jl", + "_superseded/src/Validation/PurgedKFoldStacked.jl", + "_superseded/src/Validation/PurgedKFoldStackedSplit.jl", + "_superseded/src/Validation/purgeTrainingTimes.jl", + "docs/.gitignore", + "docs/Project.toml", "docs/causal_factor_analysis.md", "docs/compatibility.md", + "docs/make.jl", + "docs/src/index.md", "examples/causal_factor_analysis_quickstart.jl", "ext/RiskLabAIDeepBSDEExt.jl", "src/Backtest/Backtest.jl", @@ -35,12 +95,16 @@ "src/Backtest/TestSetOverfitting.jl", "src/Backtests/ProbabilityOfBacktestOverfitting.jl", "src/BetSize/BetSizing.jl", + "src/CausalFactorAnalysis/AllocationDiagnostics.jl", "src/CausalFactorAnalysis/CausalFactorAnalysis.jl", "src/CausalFactorAnalysis/FactorMirage.jl", + "src/CausalFactorAnalysis/FalseDiscoveryRates.jl", "src/CausalFactorAnalysis/GraphIdentification.jl", + "src/CausalFactorAnalysis/GraphRoles.jl", "src/CausalFactorAnalysis/Optimizer.jl", "src/CausalFactorAnalysis/Protocol.jl", "src/CausalFactorAnalysis/SpecificationDiagnostics.jl", + "src/CausalFactorAnalysis/StructuralModels.jl", "src/CausalFactorAnalysis/TreatmentEffects.jl", "src/Cluster/Cluster.jl", "src/Cluster/Clustering.jl", @@ -86,11 +150,18 @@ "src/Validation/PathAdaptiveCPCV.jl", "src/Validation/PathBaggedCPCV.jl", "src/Validation/Validation.jl", + "test/causal_factor_analysis/fixtures/additive_numeric_parity.tsv", + "test/causal_factor_analysis/test_additive_numeric_parity.jl", + "test/causal_factor_analysis/test_allocation_diagnostics.jl", "test/causal_factor_analysis/test_factor_mirage.jl", + "test/causal_factor_analysis/test_false_discovery_rates.jl", + "test/causal_factor_analysis/test_generalized_factor_mirage.jl", "test/causal_factor_analysis/test_graph_identification.jl", + "test/causal_factor_analysis/test_graph_roles.jl", "test/causal_factor_analysis/test_optimizer.jl", "test/causal_factor_analysis/test_protocol.jl", "test/causal_factor_analysis/test_specification_diagnostics.jl", + "test/causal_factor_analysis/test_structural_models.jl", "test/causal_factor_analysis/test_treatment_effects.jl", "test/fixtures/pelt_x1.txt", "test/fixtures/pelt_x2.txt", @@ -99,10 +170,99 @@ "test/preserved_runtests.jl", "test/runtests.jl" ], - "local_control_files_excluded_from_artifacts": [ - "PACKAGE_IDENTITY.json" + "candidate_only_control_files": [], + "candidate_only_control_policy": "Analytical ledgers and verification records remain outside the candidate root.", + "baseline_active_release_files": [ + ".github/workflows/CI.yml", + "LICENSE", + "PACKAGE_FILES.json", + "PACKAGE_INSPECTION.md", + "PUBLIC_API.json", + "Project.toml", + "README.md", + "RELEASE_NOTES_NEXT_MAJOR.md", + "TEST_INVENTORY.json", + "docs/causal_factor_analysis.md", + "docs/compatibility.md", + "examples/causal_factor_analysis_quickstart.jl", + "ext/RiskLabAIDeepBSDEExt.jl", + "src/Backtest/Backtest.jl", + "src/Backtest/BacktestStatistics.jl", + "src/Backtest/BacktestSyntheticData.jl", + "src/Backtest/BetSizing.jl", + "src/Backtest/MultipleTesting.jl", + "src/Backtest/OUTradingRules.jl", + "src/Backtest/ProbabilisticSharpeRatio.jl", + "src/Backtest/ProbabilityOfBacktestOverfitting.jl", + "src/Backtest/StrategyRisk.jl", + "src/Backtest/TestSetOverfitting.jl", + "src/Backtests/ProbabilityOfBacktestOverfitting.jl", + "src/BetSize/BetSizing.jl", + "src/CausalFactorAnalysis/CausalFactorAnalysis.jl", + "src/CausalFactorAnalysis/FactorMirage.jl", + "src/CausalFactorAnalysis/GraphIdentification.jl", + "src/CausalFactorAnalysis/Optimizer.jl", + "src/CausalFactorAnalysis/Protocol.jl", + "src/CausalFactorAnalysis/SpecificationDiagnostics.jl", + "src/CausalFactorAnalysis/TreatmentEffects.jl", + "src/Cluster/Cluster.jl", + "src/Cluster/Clustering.jl", + "src/Data/Data.jl", + "src/Data/Denoise/Denoising.jl", + "src/Data/Differentiation/Differentiation.jl", + "src/Data/Distance/DistanceMetric.jl", + "src/Data/Labeling/FinancialLabels.jl", + "src/Data/Labeling/Labeling.jl", + "src/Data/Structures/abstract_bars.jl", + "src/Data/Structures/abstract_imbalance_bars.jl", + "src/Data/Structures/abstract_information_driven_bars.jl", + "src/Data/Structures/abstract_run_bars.jl", + "src/Data/Structures/imbalance_bars.jl", + "src/Data/Structures/run_bars.jl", + "src/Data/Structures/standard_bars.jl", + "src/Data/Structures/time_bars.jl", + "src/Data/Structures/types.jl", + "src/Data/SyntheticData/SyntheticData.jl", + "src/Data/Weights/SampleWeight.jl", + "src/Ensemble/BaggingAccuracy.jl", + "src/Ensemble/Ensemble.jl", + "src/Features/EntropyFeatures.jl", + "src/Features/FeatureImportance.jl", + "src/Features/Features.jl", + "src/Features/MicrostructuralFeatures.jl", + "src/Features/StructuralBreaks.jl", + "src/Optimization/Hedging.jl", + "src/Optimization/HierarchicalRiskParity.jl", + "src/Optimization/NestedClusteredOptimization.jl", + "src/Optimization/Optimization.jl", + "src/Pde/DeepBSDESolver.jl", + "src/Pde/Equations.jl", + "src/Pde/Pde.jl", + "src/RiskLabAI.jl", + "src/Utils/Utils.jl", + "src/Utils/constants.jl", + "src/Utils/ewma.jl", + "src/Utils/field_inheritance.jl", + "src/Validation/CrossValScore.jl", + "src/Validation/CrossValidators.jl", + "src/Validation/HyperParameterTuning.jl", + "src/Validation/PathAdaptiveCPCV.jl", + "src/Validation/PathBaggedCPCV.jl", + "src/Validation/Validation.jl", + "test/causal_factor_analysis/test_factor_mirage.jl", + "test/causal_factor_analysis/test_graph_identification.jl", + "test/causal_factor_analysis/test_optimizer.jl", + "test/causal_factor_analysis/test_protocol.jl", + "test/causal_factor_analysis/test_specification_diagnostics.jl", + "test/causal_factor_analysis/test_treatment_effects.jl", + "test/fixtures/pelt_x1.txt", + "test/fixtures/pelt_x2.txt", + "test/fixtures/pelt_x3.txt", + "test/fixtures/vrsadf_y.txt", + "test/preserved_runtests.jl", + "test/runtests.jl" ], - "release_source_file_count": 88, + "release_source_file_count": 99, "release_source_files": [ ".github/workflows/CI.yml", "LICENSE", @@ -129,12 +289,16 @@ "src/Backtest/TestSetOverfitting.jl", "src/Backtests/ProbabilityOfBacktestOverfitting.jl", "src/BetSize/BetSizing.jl", + "src/CausalFactorAnalysis/AllocationDiagnostics.jl", "src/CausalFactorAnalysis/CausalFactorAnalysis.jl", "src/CausalFactorAnalysis/FactorMirage.jl", + "src/CausalFactorAnalysis/FalseDiscoveryRates.jl", "src/CausalFactorAnalysis/GraphIdentification.jl", + "src/CausalFactorAnalysis/GraphRoles.jl", "src/CausalFactorAnalysis/Optimizer.jl", "src/CausalFactorAnalysis/Protocol.jl", "src/CausalFactorAnalysis/SpecificationDiagnostics.jl", + "src/CausalFactorAnalysis/StructuralModels.jl", "src/CausalFactorAnalysis/TreatmentEffects.jl", "src/Cluster/Cluster.jl", "src/Cluster/Clustering.jl", @@ -180,11 +344,18 @@ "src/Validation/PathAdaptiveCPCV.jl", "src/Validation/PathBaggedCPCV.jl", "src/Validation/Validation.jl", + "test/causal_factor_analysis/fixtures/additive_numeric_parity.tsv", + "test/causal_factor_analysis/test_additive_numeric_parity.jl", + "test/causal_factor_analysis/test_allocation_diagnostics.jl", "test/causal_factor_analysis/test_factor_mirage.jl", + "test/causal_factor_analysis/test_false_discovery_rates.jl", + "test/causal_factor_analysis/test_generalized_factor_mirage.jl", "test/causal_factor_analysis/test_graph_identification.jl", + "test/causal_factor_analysis/test_graph_roles.jl", "test/causal_factor_analysis/test_optimizer.jl", "test/causal_factor_analysis/test_protocol.jl", "test/causal_factor_analysis/test_specification_diagnostics.jl", + "test/causal_factor_analysis/test_structural_models.jl", "test/causal_factor_analysis/test_treatment_effects.jl", "test/fixtures/pelt_x1.txt", "test/fixtures/pelt_x2.txt", @@ -193,8 +364,509 @@ "test/preserved_runtests.jl", "test/runtests.jl" ], + "release_file_list_sha256": "95007A9CFC0EC99FB248BF64418BF8E7F6C114B429485BFA88D545DF58585509", + "release_file_records_excluding_self": [ + { + "path": ".github/workflows/CI.yml", + "size": 6102, + "sha256": "B97DB6BB229BAD614E9972BA2283D570CD1424BCE7484B9115468C032356AC6C" + }, + { + "path": "LICENSE", + "size": 1524, + "sha256": "C218B2652D7FC88A70415780B13ABC03BDCE419197CBA3C47FBBAFEF396686EA" + }, + { + "path": "PACKAGE_INSPECTION.md", + "size": 3037, + "sha256": "F93D99688775BCE82CA92E356AB695F409B1DE3538C62622B1219ABFC63590E8" + }, + { + "path": "PUBLIC_API.json", + "size": 36329, + "sha256": "0D7259C22F7DC28D1AAE70FA2E4F1172CEE7A1541306BD756F236579D281A066" + }, + { + "path": "Project.toml", + "size": 1416, + "sha256": "E2D051F2F44B1EEF3EBDCFA210A8514A428F1D6B2E84F1CB7C8B8C8706833A7D" + }, + { + "path": "README.md", + "size": 4484, + "sha256": "0610FE5E0C75DD54F5C3322A8A6BFE3E3510A4102D29E1860AB2B02AAC847F24" + }, + { + "path": "RELEASE_NOTES_NEXT_MAJOR.md", + "size": 2088, + "sha256": "E8297D7498AA962EA5B0B95E4BCFC3603658448267828B5668FF7A4A03004D9D" + }, + { + "path": "TEST_INVENTORY.json", + "size": 4818, + "sha256": "B3F94657405E6C6EC7948B2B96900E02ADAB57B3B86CC840725BFC92AE959DF2" + }, + { + "path": "docs/causal_factor_analysis.md", + "size": 9205, + "sha256": "99F3A6789B3F0C1A547EC919F47A766797BAFE34939C7ACBEDE2D3F9A4AC038C" + }, + { + "path": "docs/compatibility.md", + "size": 4589, + "sha256": "6BC8E4494B75D267652BE9C41E02AED7545395AEC2DC964F042CAC0AD9508091" + }, + { + "path": "examples/causal_factor_analysis_quickstart.jl", + "size": 1674, + "sha256": "D5283DD5BAE3FE8B34E9EBD34CBAE0E533C5D86607667D223DB094A8BAFE49E7" + }, + { + "path": "ext/RiskLabAIDeepBSDEExt.jl", + "size": 150, + "sha256": "D5807D752841D38A0F64E386E377D59217CEBFE47B15A4E4B29B823971078332" + }, + { + "path": "src/Backtest/Backtest.jl", + "size": 3320, + "sha256": "2BD5FD263B4BE056A9846A915E475A33014CF356FDD9D2201ED6003D99126118" + }, + { + "path": "src/Backtest/BacktestStatistics.jl", + "size": 14092, + "sha256": "EEDD8A638370C50C97C5788116857C805B1E7CF9DDA8FBE57DA499053F55DB20" + }, + { + "path": "src/Backtest/BacktestSyntheticData.jl", + "size": 2657, + "sha256": "B5A098CCA8B057A4A480FF38E9D4CB9AD02BB8C2C2E5D41E65E8BFE8244ABB66" + }, + { + "path": "src/Backtest/BetSizing.jl", + "size": 8349, + "sha256": "AD2C6BD4359D48A9A9520779C09B8EE2B5540D50C2D9E730994F239C0D809B5F" + }, + { + "path": "src/Backtest/MultipleTesting.jl", + "size": 5597, + "sha256": "2773E3D44EBBF2D5BC3FAB402B6D169B0FFB9B8F42F5DABC972322D1983063C7" + }, + { + "path": "src/Backtest/OUTradingRules.jl", + "size": 9495, + "sha256": "F744DC3780A44D696817B6FCBE140BADA6BF907B0435C9729E4E16ED6C8DA029" + }, + { + "path": "src/Backtest/ProbabilisticSharpeRatio.jl", + "size": 7750, + "sha256": "95F644FB495715828470F111ACE5E8D7A6439E2E601D135904441FB2085E1D20" + }, + { + "path": "src/Backtest/ProbabilityOfBacktestOverfitting.jl", + "size": 3865, + "sha256": "84AC8ADBF512A80A69DBD5D4836F31249A09B3BC1EB5AFFD9C43F8702C825523" + }, + { + "path": "src/Backtest/StrategyRisk.jl", + "size": 7067, + "sha256": "308E5968BA23DBCAC94BF6DA99B1871A947AEBFEB579F99346C130A93E57FBF1" + }, + { + "path": "src/Backtest/TestSetOverfitting.jl", + "size": 6199, + "sha256": "6BCAD71DB9AB6D2A33B582F19C7A78D783C34DA602D99AA35588BF64006391B8" + }, + { + "path": "src/Backtests/ProbabilityOfBacktestOverfitting.jl", + "size": 2736, + "sha256": "8DE0E7C5A69B99A175F0C2667EACAC1C877DDA91DB5524223118F3C5D22EEEC6" + }, + { + "path": "src/BetSize/BetSizing.jl", + "size": 13711, + "sha256": "6B56738C2BB16CDF04F2DA440E5D20599CB6FD529B35B15529069BC0948AF907" + }, + { + "path": "src/CausalFactorAnalysis/AllocationDiagnostics.jl", + "size": 9683, + "sha256": "5ACA69BAF9B91CC50335DC1C102F3679D07111C9A59E6D0F58BCB36FBF4C68D4" + }, + { + "path": "src/CausalFactorAnalysis/CausalFactorAnalysis.jl", + "size": 3297, + "sha256": "9AD230522D874B3B6FB594C56BB17B1B1583CA5ABD1CFA25DB59C87CCF558C7F" + }, + { + "path": "src/CausalFactorAnalysis/FactorMirage.jl", + "size": 11607, + "sha256": "F8EEBA4077EDD9657EB2C71804F33E8F0562811EF4862C58AAD77C3217DF3853" + }, + { + "path": "src/CausalFactorAnalysis/FalseDiscoveryRates.jl", + "size": 22149, + "sha256": "DF428E40B09930E03888758286125D2CB658A567D26A45D34E13A3011D05BAC5" + }, + { + "path": "src/CausalFactorAnalysis/GraphIdentification.jl", + "size": 29966, + "sha256": "D0734576F874404FCE5C6244FE4E9AF1F4FF3C07BBFEE58D43098E76FD3121FB" + }, + { + "path": "src/CausalFactorAnalysis/GraphRoles.jl", + "size": 5922, + "sha256": "A6A1A0B151E9FA097B574113820150BD76ADBD63EFBA0794F5483ED76E3F0371" + }, + { + "path": "src/CausalFactorAnalysis/Optimizer.jl", + "size": 5001, + "sha256": "1896FC5BCD0E43691EAB233E86300872D54BD68ECBFE125A88CB5AD736B7E506" + }, + { + "path": "src/CausalFactorAnalysis/Protocol.jl", + "size": 57203, + "sha256": "95DB2ABAA05609F210F77B478457A75800020EAA85806E90018D78C247964E50" + }, + { + "path": "src/CausalFactorAnalysis/SpecificationDiagnostics.jl", + "size": 6811, + "sha256": "3FDB30F7D87DA1F94DC5234268E48AF646C069C4B9D5E78E89B033644C4CB93B" + }, + { + "path": "src/CausalFactorAnalysis/StructuralModels.jl", + "size": 7306, + "sha256": "D6ACB5A5EB64CB3A371FAC83E566978A2B46214E2F6D1863A2F6FBD207B2F848" + }, + { + "path": "src/CausalFactorAnalysis/TreatmentEffects.jl", + "size": 8446, + "sha256": "23BD47E0B88E8C73C0BA362CDDC3FE6B84F989CF590F0E4AF7410A765344FD4F" + }, + { + "path": "src/Cluster/Cluster.jl", + "size": 777, + "sha256": "1370F63F6936C27FF5928B2DCAF887570A32999ADD6DBDA6B87A68749F23D9CB" + }, + { + "path": "src/Cluster/Clustering.jl", + "size": 11078, + "sha256": "5B71F12566C47A9014007A4B6A845F0D338D7342C00AE96B3DF78965E5B8D8DC" + }, + { + "path": "src/Data/Data.jl", + "size": 3554, + "sha256": "F9A08E3A514DADCBC14F556B40D2D07046D06AF6C9CE0D670A0C013F793771B7" + }, + { + "path": "src/Data/Denoise/Denoising.jl", + "size": 12621, + "sha256": "C76EB6429BBBFC13A44BDA56FBE04BAC23B2CFB2AF358945B31614863CF680CD" + }, + { + "path": "src/Data/Differentiation/Differentiation.jl", + "size": 13364, + "sha256": "3721BBE9A2F1FE3BB1210CA44D86FF81AB286BC53975CD6A8BDC2FD9FCCCCC54" + }, + { + "path": "src/Data/Distance/DistanceMetric.jl", + "size": 11667, + "sha256": "67D42574E5B1CB27786ACF91176E307FAB59189DABC33820FC431410DBDDFCC6" + }, + { + "path": "src/Data/Labeling/FinancialLabels.jl", + "size": 2921, + "sha256": "00646787E0D55B3CF59B8CF575030B4008154BF6BA917F48B1C4822FF4A23F4E" + }, + { + "path": "src/Data/Labeling/Labeling.jl", + "size": 9735, + "sha256": "D487F3ED3B0CDEF04C40DF7D57724FC0220D16AB3144FB1C28889799AC1F34FD" + }, + { + "path": "src/Data/Structures/abstract_bars.jl", + "size": 4774, + "sha256": "646E7D7C75BB595752BCC0F7C433E6C951EC908D70471A099A45B26015A5D87C" + }, + { + "path": "src/Data/Structures/abstract_imbalance_bars.jl", + "size": 4580, + "sha256": "13984D66AF1F7746F8138E8F47C4402BD3802E7C198F39DCF5490295D76938F4" + }, + { + "path": "src/Data/Structures/abstract_information_driven_bars.jl", + "size": 2340, + "sha256": "572B41B860A867197E0FF732E27329F22D2AAA38751642E62700DC2D11987D31" + }, + { + "path": "src/Data/Structures/abstract_run_bars.jl", + "size": 6878, + "sha256": "82CA0C4087DD54B3E314F7D16A9146557AA97800953FFC26D1002310258D2EFE" + }, + { + "path": "src/Data/Structures/imbalance_bars.jl", + "size": 3125, + "sha256": "A2887D6B3CBA1A9BF71CA7581E2E13E2BD82854D5B39F9A268B1924D40F77DE6" + }, + { + "path": "src/Data/Structures/run_bars.jl", + "size": 2978, + "sha256": "420900A27ABED792A063AC043B6825FF5DBB150B8E4018BC341C0DED78A51847" + }, + { + "path": "src/Data/Structures/standard_bars.jl", + "size": 2402, + "sha256": "B693ECF7F95AEDBF3CD49AD9C14E1E7D70CA4B85591121443E01F4A557C9D637" + }, + { + "path": "src/Data/Structures/time_bars.jl", + "size": 4006, + "sha256": "EFB7FA94FA17DD236BD40DE2C1528F0D5355314C83CA7C1076036726A1A33A70" + }, + { + "path": "src/Data/Structures/types.jl", + "size": 871, + "sha256": "BF7A371208DE55E389A9643069A77D6422BBDE327053241C18EA6B5DB5CB0618" + }, + { + "path": "src/Data/SyntheticData/SyntheticData.jl", + "size": 14074, + "sha256": "90A2D56DEBA678D06C893EC450E31DF40DAC18C639712A156A49E53A37608AE4" + }, + { + "path": "src/Data/Weights/SampleWeight.jl", + "size": 5700, + "sha256": "28778ED250F4F770F873418DB240DE547E1B2FA3530D40B2AD94F7972C396642" + }, + { + "path": "src/Ensemble/BaggingAccuracy.jl", + "size": 5870, + "sha256": "C0291FAFDF479C23F3030814746C9D7771B7BE5748607ED8BBC41D4363D33AF5" + }, + { + "path": "src/Ensemble/Ensemble.jl", + "size": 479, + "sha256": "BDB8AB43A20557E97427BBDCFC9CE68C5BAA7993B59001A500C781DE96239ECD" + }, + { + "path": "src/Features/EntropyFeatures.jl", + "size": 13734, + "sha256": "05608B355EAA975210B79A930E49025C874FB0CB56AB8E61145BFC372AACC08C" + }, + { + "path": "src/Features/FeatureImportance.jl", + "size": 20857, + "sha256": "CD6B2D1C57E1AD6D0C2FBED9A0BBFFD37D7542C982149950DB26132FC831D526" + }, + { + "path": "src/Features/Features.jl", + "size": 2062, + "sha256": "D0E2A8CF861445ED8811A05E2AAA615EAE71B71F551A8561FF43756D2F685772" + }, + { + "path": "src/Features/MicrostructuralFeatures.jl", + "size": 9322, + "sha256": "C43CD42F440890E9E60AFC896E324F3A660A0D83C9DA179D58CE87D203B5A4AE" + }, + { + "path": "src/Features/StructuralBreaks.jl", + "size": 28536, + "sha256": "8950B7A405C19900A26312332F2069E9133489280377B888A5D9C5F7B73686F9" + }, + { + "path": "src/Optimization/Hedging.jl", + "size": 1542, + "sha256": "5D53049AB53DA667DAB50C8A85C0C4AEC8B72FB173EF10492CE29CAC2DD4C4CF" + }, + { + "path": "src/Optimization/HierarchicalRiskParity.jl", + "size": 5366, + "sha256": "D183F89F5630B7A90698B0C5E8FD4B12BE4C2CB35B63584D3E68BE228E66DF3F" + }, + { + "path": "src/Optimization/NestedClusteredOptimization.jl", + "size": 3203, + "sha256": "606C77D03C188329A2D34A47B66E30F9EF071830F759FCAF3212174F0C75A040" + }, + { + "path": "src/Optimization/Optimization.jl", + "size": 1046, + "sha256": "F3F2B5BF989A38B2597212B189D46B3F15822EC70D0A10B88665B8232749B111" + }, + { + "path": "src/Pde/DeepBSDESolver.jl", + "size": 4156, + "sha256": "98713B61985616C1B75A75AEEFDFC04EB098CCE8B113DC82C30E66C290F5CE2B" + }, + { + "path": "src/Pde/Equations.jl", + "size": 6691, + "sha256": "E9FDB03662BFA332071DFEE0B5EAD08EB9083013394D2A467E4E74850619D9F8" + }, + { + "path": "src/Pde/Pde.jl", + "size": 1150, + "sha256": "83EC54078F611573F5BDDAAAB1A196FDBCF5BDF60441D73F15A5DFACC8960318" + }, + { + "path": "src/RiskLabAI.jl", + "size": 9192, + "sha256": "F1568851BCEA6C59D24D2BD3362E91A74A1FA2EA96CDFDB10E25B8D80BE2600F" + }, + { + "path": "src/Utils/Utils.jl", + "size": 1422, + "sha256": "00040198C01FAA14E1382615E45FB9C4134E811358873ACAADE6395300AF6757" + }, + { + "path": "src/Utils/constants.jl", + "size": 1878, + "sha256": "4375C297224F43D46DCBB3C48CDE48C03E0411003A8EB7BE0F7043D21D0EA1C0" + }, + { + "path": "src/Utils/ewma.jl", + "size": 603, + "sha256": "1859782577AB7D54D2ABDEA9E2D55E27E1A5085DF9CAC4747BD07F531D802B50" + }, + { + "path": "src/Utils/field_inheritance.jl", + "size": 1354, + "sha256": "1B49243A727EF52DD281E5C99833F4628D11E171A362DD93F0569A29BBC68615" + }, + { + "path": "src/Validation/CrossValScore.jl", + "size": 2838, + "sha256": "0B201D8E86ED57E48739263D24B5D6E15A63920AF57F25677A0A01BAC26F1D75" + }, + { + "path": "src/Validation/CrossValidators.jl", + "size": 11066, + "sha256": "7CD01DAA03DCE65A3FD8FD63A659CA49F41C065EBCB57D74A7143D04A02EF274" + }, + { + "path": "src/Validation/HyperParameterTuning.jl", + "size": 9451, + "sha256": "6029124C2EEBF947577E8F92DBE67E697D388CAB71037ED4BD26FF6B929970F0" + }, + { + "path": "src/Validation/PathAdaptiveCPCV.jl", + "size": 7537, + "sha256": "920709AD376A191CFCD7231064802678E3C65A086C08C6374C4A9D02FF628BE8" + }, + { + "path": "src/Validation/PathBaggedCPCV.jl", + "size": 4015, + "sha256": "C81794F73B57552CB338DB421F796A64011B08B5357EC925DFE5CFFB662CE4E8" + }, + { + "path": "src/Validation/Validation.jl", + "size": 1729, + "sha256": "8D77D7E5FDC0C523C1832A749EB945AB1651DD02CBA839170372DC1262AC04C1" + }, + { + "path": "test/causal_factor_analysis/fixtures/additive_numeric_parity.tsv", + "size": 1200, + "sha256": "E2B46AB385DB863D9F4C764F3754D6C0931ED7C62DCB2C589927DB924986DD7E" + }, + { + "path": "test/causal_factor_analysis/test_additive_numeric_parity.jl", + "size": 6777, + "sha256": "8668086C0D082ABDD4C49B524EC5BBD4B18570743F1134F8C226692D0F0A026E" + }, + { + "path": "test/causal_factor_analysis/test_allocation_diagnostics.jl", + "size": 13344, + "sha256": "6352768CFCD30CC2298F86E849306FEFCA8873DCED93CD1F946E843BEBC786EB" + }, + { + "path": "test/causal_factor_analysis/test_factor_mirage.jl", + "size": 2829, + "sha256": "84B194306913AFE850F6E4B56761491AEA2B13F180BFF24FD83BB20FE10151D7" + }, + { + "path": "test/causal_factor_analysis/test_false_discovery_rates.jl", + "size": 11250, + "sha256": "841E2B0FEDF50CB8ADA2B83E029EE9B504D223173769FE099221BFFA8FDFCC3C" + }, + { + "path": "test/causal_factor_analysis/test_generalized_factor_mirage.jl", + "size": 8265, + "sha256": "ED02E433879FC91C125C29478681CC0653BC532EB34D49932A4671585252A994" + }, + { + "path": "test/causal_factor_analysis/test_graph_identification.jl", + "size": 18944, + "sha256": "B15F623E6F474B706F3AA00AB69D693C0F496494DB0E537D824F3B429363D22F" + }, + { + "path": "test/causal_factor_analysis/test_graph_roles.jl", + "size": 6590, + "sha256": "1FA00ADE370AD3E239258AF501D86767168C1A35095F16E108BD3D412201A6B4" + }, + { + "path": "test/causal_factor_analysis/test_optimizer.jl", + "size": 3424, + "sha256": "C3DD2CB2F226D9C8ABF6C3E645929007BC811419321E10181A5CF8C5AAAC6ECB" + }, + { + "path": "test/causal_factor_analysis/test_protocol.jl", + "size": 11071, + "sha256": "5916A06D7D186DA6A74FA0FD23DC7EDD29BA75BACCBA92CA53E3AB94C201109D" + }, + { + "path": "test/causal_factor_analysis/test_specification_diagnostics.jl", + "size": 1974, + "sha256": "0BBB85F99A06450D9940FBFC152C0F239159DA6CC4AB75D6D5A34490E23B717E" + }, + { + "path": "test/causal_factor_analysis/test_structural_models.jl", + "size": 6678, + "sha256": "24FCD62D2EA75F38D7F9D1634FF06AA1694E75A9BB3F1880C91F3E88F4F88152" + }, + { + "path": "test/causal_factor_analysis/test_treatment_effects.jl", + "size": 2220, + "sha256": "1D47C23D447AB8C50B8BD013AA6FA3ACA2A577924FFC0D17656BAA9B95CBCAEF" + }, + { + "path": "test/fixtures/pelt_x1.txt", + "size": 4560, + "sha256": "D91CCA3A9AC511AD7E57690342491639C2221B7D6971A84D48C06142DCBF7EBF" + }, + { + "path": "test/fixtures/pelt_x2.txt", + "size": 3898, + "sha256": "A6ED3EC032127997915CC490713622C466060B09A196DB6E64B71E75C1F3A9D2" + }, + { + "path": "test/fixtures/pelt_x3.txt", + "size": 6071, + "sha256": "4ED138F86A26DBEAA424E52656286EFE0ECEF8EC9F5901F4F39B6653348F91CA" + }, + { + "path": "test/fixtures/vrsadf_y.txt", + "size": 2225, + "sha256": "15174F32C7EA4FCF7A158C80C47ABDFB25B1DEFBEDD2DA0B1103B6B2AFF33AD5" + }, + { + "path": "test/preserved_runtests.jl", + "size": 72675, + "sha256": "C312ED56CF28C0D980B88D3AC37AEA01028F9929840D4786184C7362889725E2" + }, + { + "path": "test/runtests.jl", + "size": 4087, + "sha256": "509E9630B753FE1BF9A63D3FE5F729C89C996A5BFC5FCA892E00CCA3F12CE088" + } + ], + "self_hash_policy": "PACKAGE_FILES.json is listed but omitted from its own hash records to avoid a self-referential digest.", "preserved_repository_only_file_count": 60, "preserved_repository_only_files": [ + ".github/workflows/CompatHelper.yml", + ".github/workflows/TagBot.yml", + ".gitignore", + "CHANGELOG.md", + "LICENSE.txt", + "PARITY.md", + "RECONSTRUCTION_PLAN.md", "_superseded/README.md", "_superseded/src/Backtests/BacktestStatistics.jl", "_superseded/src/Backtests/BacktestSyntheticData.jl", @@ -244,21 +916,317 @@ "_superseded/src/Validation/PurgedKFoldStacked.jl", "_superseded/src/Validation/PurgedKFoldStackedSplit.jl", "_superseded/src/Validation/purgeTrainingTimes.jl", - ".github/workflows/CompatHelper.yml", - ".github/workflows/TagBot.yml", - ".gitignore", - "CHANGELOG.md", "docs/.gitignore", - "docs/make.jl", "docs/Project.toml", - "docs/src/index.md", - "LICENSE.txt", - "PARITY.md", - "RECONSTRUCTION_PLAN.md" + "docs/make.jl", + "docs/src/index.md" + ], + "preserved_repository_only_records": [ + { + "path": ".github/workflows/CompatHelper.yml", + "size": 490, + "sha256": "DC50C96CDE509B6F61E1CB1BB1BF6CFBB2BAF751EEEE2851EE15CC4BE6C40DB0" + }, + { + "path": ".github/workflows/TagBot.yml", + "size": 439, + "sha256": "E77C5742742B9C1180723DC5BCE6D24B8D4BF09579CCBC3F40F64C3C96D79103" + }, + { + "path": ".gitignore", + "size": 817, + "sha256": "8C378C84EB5D4F9734E1B98ADA2F5149AB446CA23EC1C287A54DF565FAFEA86E" + }, + { + "path": "CHANGELOG.md", + "size": 11280, + "sha256": "D2ABC6637111D6FCFE34158F98CC6C2E6A79FA704D6E7308A3B09B6893888097" + }, + { + "path": "LICENSE.txt", + "size": 1694, + "sha256": "C5672228DA6CC339847A35B28525673C604933280027EA19FCBF68A3CF78C249" + }, + { + "path": "PARITY.md", + "size": 42742, + "sha256": "B001EB2E401E079269836795A4C1EBDE44D8FE15CD590ACEAE71FB4084FE82F4" + }, + { + "path": "RECONSTRUCTION_PLAN.md", + "size": 7108, + "sha256": "ED444661E4CC7FC89C1DE9383A638B666E9AB99ED99A93C172D9B3EF8D24DD18" + }, + { + "path": "_superseded/README.md", + "size": 1564, + "sha256": "44582035FDF683276D03D8CF2A0CB7A8E04BA385438DF40EF6F1E0559891336A" + }, + { + "path": "_superseded/src/Backtests/BacktestStatistics.jl", + "size": 7305, + "sha256": "136C5A17B644C7D290619537CBAD8C98090129C4F8BBFA54BE5B72E0FEECADE1" + }, + { + "path": "_superseded/src/Backtests/BacktestSyntheticData.jl", + "size": 6028, + "sha256": "026790266E3B658DCBF1830F307100D73D46FBFFCF7E60942CA64D43E5D0B143" + }, + { + "path": "_superseded/src/Backtests/StrategyRisk.jl", + "size": 6415, + "sha256": "4706AEA04BE14416FB4669982BE30F13966B675CCEBCAA0990E8C325F2D04413" + }, + { + "path": "_superseded/src/Backtests/TestSetOverfitting.jl", + "size": 6420, + "sha256": "435B0ED8723874FB1669BAAC7A67D53AAE29F4AEF3C679B0F99A82BB8AD62509" + }, + { + "path": "_superseded/src/BetSize/MixtureModel.jl", + "size": 7856, + "sha256": "FA6E679D42B1A15482CEE3CB18AA683E2F12F449C3EDD7C15F2D7CA16184AFA1" + }, + { + "path": "_superseded/src/Calibration/HyperParameterTuning.jl", + "size": 457, + "sha256": "7AACCA92CC33EA87879673543F7BA52E3073156CA65BE7D8011B79FFCC4DE115" + }, + { + "path": "_superseded/src/Controller/all.jl", + "size": 1, + "sha256": "01BA4719C80B6FE911B091A7C05124B64EEECE964E09C058EF8F9805DACA546B" + }, + { + "path": "_superseded/src/Controller/bars_initializer.jl", + "size": 18150, + "sha256": "D149FA5C762F53A8A5220B812E872173FD001532DCD856C49E4A5A82A3EF1961" + }, + { + "path": "_superseded/src/Controller/data_structure_controller.jl", + "size": 7602, + "sha256": "FC4D74232DD2E77F508EF494ADEB6B506A42538A4091F44DD4C7070D347C730E" + }, + { + "path": "_superseded/src/Data/Structures/DataStructuresLopez.jl", + "size": 16700, + "sha256": "75375E55E2436EE18C89559532B4E1830678560CE57BC21EF5CE0CAE307C5815" + }, + { + "path": "_superseded/src/Differentiation/Differentiation.jl", + "size": 9343, + "sha256": "80365E2F5FACC9F99D210DDA6619AD375E8887DCCF18BEC89CB0826A5889C73E" + }, + { + "path": "_superseded/src/Ensemble/BaggingClassifierAccuracy.jl", + "size": 1292, + "sha256": "0B92553AF806FE0D28FF9ED476C155A73673AD71C3DBAF336C8A9FE0E3F5C9C7" + }, + { + "path": "_superseded/src/Features/BekkerParkinsonVolatilityEstimator.jl", + "size": 2285, + "sha256": "4DBA88463D0D6E3CC9A07157E3343F315DA59D9CDF166EEA59A1E29BE8C05736" + }, + { + "path": "_superseded/src/Features/ClusteredMeanDecreaseAccuracy.jl", + "size": 2636, + "sha256": "C9ED82D9BC035C45AC45C44B69284D6F1A09990C12CE772E472875357EA8844A" + }, + { + "path": "_superseded/src/Features/ClusteredMeanDecreaseImpurity.jl", + "size": 2760, + "sha256": "CE9AD6BE44F3D79F7BA63D6EBE08BD8456E0F9A79EBD4D7C8666EF866525DAC8" + }, + { + "path": "_superseded/src/Features/Clustering.jl", + "size": 13016, + "sha256": "AEFF1556BD06E4C61FAD153C7C7F949C85286B6909558918204018C5AA300C1F" + }, + { + "path": "_superseded/src/Features/CorwinSchultz.jl", + "size": 2882, + "sha256": "765838D1CDD7FB03753856B85FB7D4E4F310BF65D2CB8CF38FD4631FB9675A72" + }, + { + "path": "_superseded/src/Features/DistanceMetric.jl", + "size": 6123, + "sha256": "B31A7AC3E09CC213F9733EC4A5E17FCA2BB9579078F77EECB34A05F21AE6F8AC" + }, + { + "path": "_superseded/src/Features/Entropy.jl", + "size": 4993, + "sha256": "90910F1EE645D90DA2937BA3B3D1DBDECAA181AC61574DEDBC83998D11488801" + }, + { + "path": "_superseded/src/Features/GenerateSyntheticData.jl", + "size": 1573, + "sha256": "42C22399CAB4D5A4914CE9F75847EA9E3756CC4AB93CE70492DBE89D9BE7436B" + }, + { + "path": "_superseded/src/Features/MeanDecreaseAccuracy.jl", + "size": 2695, + "sha256": "8DCD92ED8BF7405B87D28EB8CB405662EF0C289155EC4AA0371AF40B1523FAC6" + }, + { + "path": "_superseded/src/Features/MeanDecreaseImpurity.jl", + "size": 1565, + "sha256": "375BCF9FA4C7A4CD9D09294B296A6E197CC5023917D389D97546110F72D9A972" + }, + { + "path": "_superseded/src/Features/OrthogonalFeatures.jl", + "size": 2070, + "sha256": "8180301D5AA7837D92A2873EE4DB3476F7E8BC7AD377D5A64877A73AB1B8FC88" + }, + { + "path": "_superseded/src/Features/SingleFeatureImportance.jl", + "size": 3048, + "sha256": "81C51D03C4AE48CBAD29E6D110795B6D1069B4160E219F0BD6428813172FBA28" + }, + { + "path": "_superseded/src/Features/StructuralBreaks/StructuralBreaks.jl", + "size": 11184, + "sha256": "71B0D44DC0BB95272FC30FBA783E30C5A48B64157A5AEADBAE4B20701BBC42C9" + }, + { + "path": "_superseded/src/Features/WeightedTau.jl", + "size": 920, + "sha256": "584F0C7061B6F17A21FECF23B4EF871219CA6B04599BE49F2661E339F819E8EC" + }, + { + "path": "_superseded/src/HPC/HPC.jl", + "size": 4002, + "sha256": "D7FF12EFFB2CA62280ABB4B12EAA076F93E36F8354F917F52E9E6F4B3AA21322" + }, + { + "path": "_superseded/src/Models/HyperParameterTuning.jl", + "size": 3491, + "sha256": "4FCAC887DA5A14DA70EEFD4A7BB4DE7652AED83B4F15836DEDFC014B8BBEA9F9" + }, + { + "path": "_superseded/src/Optimization/DistanceFromCorrelation.jl", + "size": 719, + "sha256": "2640E36C29B04B8F2EDDB361916CDE47804C154B102E0981A2495FD68F958986" + }, + { + "path": "_superseded/src/Optimization/HRP.jl", + "size": 8406, + "sha256": "596822AEE3212C9F2F0A4950A63E36FF2ABD16FA6E2C36373E7EE99A116E74F9" + }, + { + "path": "_superseded/src/Optimization/NCO.jl", + "size": 3899, + "sha256": "9B0E588AFDFD408EA15536ED03DBEECBE0F6AEE263CC7DB99D4B4994C8435279" + }, + { + "path": "_superseded/src/Optimization/QuasiDiagonal.jl", + "size": 1613, + "sha256": "B6CB9997EBA63EC89E56B1EF8556EFCD9BE89B1E1976A5620BCBD62458442303" + }, + { + "path": "_superseded/src/Risk/StrategyRisk.jl", + "size": 8567, + "sha256": "E1FD0CCFB6790587503B4902F172A214D3B5A370BCB4D8581ABFBF7186606E7B" + }, + { + "path": "_superseded/src/Utils/RollingFunctions.jl", + "size": 1931, + "sha256": "D83C4709FBBB62640A53BD3823F4EC4A595D3BB07F6B90122BB8E8A03A83150A" + }, + { + "path": "_superseded/src/Utils/VectorFunctions.jl", + "size": 1266, + "sha256": "0CDA9105D46ADCEA7058C0500E790A4DE63F46CA59C7D27F9EC9D7F5DA398752" + }, + { + "path": "_superseded/src/Validation/BacktestPathNumber.jl", + "size": 828, + "sha256": "586F13C09480B04C9BEC37E238949E1AFF1E1010B8D3C34B1FF2C1F03190103D" + }, + { + "path": "_superseded/src/Validation/CalculateEmbargoTimes.jl", + "size": 1721, + "sha256": "ABAB08116172B2DAD89FDB113C5448FFF2F5944241AF8C9D3C8A5BA4D606C028" + }, + { + "path": "_superseded/src/Validation/CrossValidationScore.jl", + "size": 3746, + "sha256": "197D6F063EE42C4073F4C26942138909D3FAC5954BB93D0483AF80D1EAFFF053" + }, + { + "path": "_superseded/src/Validation/CrossValidationScore2.jl", + "size": 4276, + "sha256": "B69F5386857D3CC50A8A1035D1AFA8FD6106F23CC8AD0715FA215C05CA5335B8" + }, + { + "path": "_superseded/src/Validation/PurgedKFold.jl", + "size": 1420, + "sha256": "CA49E850A5E05E59B5D3D7A3C3A38FF221EFF1FE63838D83479DF359C05C6D38" + }, + { + "path": "_superseded/src/Validation/PurgedKFoldCombinatorial.jl", + "size": 1762, + "sha256": "7DDBD9B04E9809D77CA2E9885FDF12C3847D140E358003F3377DBE4AC3C7CEA5" + }, + { + "path": "_superseded/src/Validation/PurgedKFoldCombinatorialSplit.jl", + "size": 4199, + "sha256": "307741A99752468EBB81B8C335A0BA219EF6140A015A2B57B61D3648BF6E19AA" + }, + { + "path": "_superseded/src/Validation/PurgedKFoldCombinatorialSplit2.jl", + "size": 3346, + "sha256": "2D9FE3C82B4CDC271FF56A08ADAC78EB1443D87F2F6A05E58C7EA5D2507576C9" + }, + { + "path": "_superseded/src/Validation/PurgedKFoldCombinatorialStacked.jl", + "size": 2466, + "sha256": "FA32A110BCCFB606A716BFF46DF25AEB82920A058072F4B36153E7C113FFACC8" + }, + { + "path": "_superseded/src/Validation/PurgedKFoldSplit.jl", + "size": 1962, + "sha256": "81397D10947A8C199D01BACCEF0CCCB71E3154B1E37DCFEE7B33EA4E61A021D1" + }, + { + "path": "_superseded/src/Validation/PurgedKFoldStacked.jl", + "size": 1720, + "sha256": "989F879BB4D798AB52DE3811DF32B02ACB8AD54ED04FB9F0939BCF13486F58C8" + }, + { + "path": "_superseded/src/Validation/PurgedKFoldStackedSplit.jl", + "size": 3248, + "sha256": "B6EF352F0007AE1367CDBF1A90ED07577D8655B06EE63508084FE67801A2C9D8" + }, + { + "path": "_superseded/src/Validation/purgeTrainingTimes.jl", + "size": 2377, + "sha256": "4626BE223150DAA0A7DF7ABCB3DD7A5BAE61AA6AC5F22DE4D1012C926EA1D8DB" + }, + { + "path": "docs/.gitignore", + "size": 13, + "sha256": "7A0620A706AE387525F9B320517C3F6F403E88141FC2871DF3B119FF00118188" + }, + { + "path": "docs/Project.toml", + "size": 59, + "sha256": "5325DD2A6D579E38013A016961367BE50C0BAA8F8079C35F5F3EEE2361C192AC" + }, + { + "path": "docs/make.jl", + "size": 602, + "sha256": "642600561444C1BF0F681417B20E4EEAB330BB14B188656754DC06AA021E7FCE" + }, + { + "path": "docs/src/index.md", + "size": 493, + "sha256": "BF7524A1161CAAF404464ADE89A20AE73DE86994D6EBEE8A71BDB09435EE447D" + } ], - "registered_tree_file_count": 148, - "registered_tree_policy": "The Julia registry tree is the exact union of release_source_files and preserved_repository_only_files. Repository-only files remain unimported and are not part of the public runtime API.", - "runtime_source_file_count": 63, + "repository_tree_file_count": 159, + "repository_tree_file_list_sha256": "D2AB81F318916B373F0041F4C616CB21E48935CD7E1183F007A330F5EA66237F", + "repository_tree_policy": "The repository tree is the exact disjoint union of active release-source files and already-public repository-only files. Repository-only files are preserved but are not wheel payload members or active package load roots.", + "runtime_source_file_count": 67, "runtime_source_files": [ "src/Backtest/Backtest.jl", "src/Backtest/BacktestStatistics.jl", @@ -272,12 +1240,16 @@ "src/Backtest/TestSetOverfitting.jl", "src/Backtests/ProbabilityOfBacktestOverfitting.jl", "src/BetSize/BetSizing.jl", + "src/CausalFactorAnalysis/AllocationDiagnostics.jl", "src/CausalFactorAnalysis/CausalFactorAnalysis.jl", "src/CausalFactorAnalysis/FactorMirage.jl", + "src/CausalFactorAnalysis/FalseDiscoveryRates.jl", "src/CausalFactorAnalysis/GraphIdentification.jl", + "src/CausalFactorAnalysis/GraphRoles.jl", "src/CausalFactorAnalysis/Optimizer.jl", "src/CausalFactorAnalysis/Protocol.jl", "src/CausalFactorAnalysis/SpecificationDiagnostics.jl", + "src/CausalFactorAnalysis/StructuralModels.jl", "src/CausalFactorAnalysis/TreatmentEffects.jl", "src/Cluster/Cluster.jl", "src/Cluster/Clustering.jl", @@ -324,13 +1296,20 @@ "src/Validation/PathBaggedCPCV.jl", "src/Validation/Validation.jl" ], - "test_file_count": 12, + "test_file_count": 19, "test_files": [ + "test/causal_factor_analysis/fixtures/additive_numeric_parity.tsv", + "test/causal_factor_analysis/test_additive_numeric_parity.jl", + "test/causal_factor_analysis/test_allocation_diagnostics.jl", "test/causal_factor_analysis/test_factor_mirage.jl", + "test/causal_factor_analysis/test_false_discovery_rates.jl", + "test/causal_factor_analysis/test_generalized_factor_mirage.jl", "test/causal_factor_analysis/test_graph_identification.jl", + "test/causal_factor_analysis/test_graph_roles.jl", "test/causal_factor_analysis/test_optimizer.jl", "test/causal_factor_analysis/test_protocol.jl", "test/causal_factor_analysis/test_specification_diagnostics.jl", + "test/causal_factor_analysis/test_structural_models.jl", "test/causal_factor_analysis/test_treatment_effects.jl", "test/fixtures/pelt_x1.txt", "test/fixtures/pelt_x2.txt", @@ -341,6 +1320,6 @@ ], "wheel_python_source_members": [], "generated_metadata_members": { - "policy": "Generated package-manager metadata is not pre-enumerated by filename here; the future artifact inspector must validate its normalized field set and reject undeclared payload files." + "policy": "Future package-manager-generated metadata must be validated by normalized fields and must not introduce undeclared payload files." } } diff --git a/PACKAGE_INSPECTION.md b/PACKAGE_INSPECTION.md index ee32b6e..b3651b0 100644 --- a/PACKAGE_INSPECTION.md +++ b/PACKAGE_INSPECTION.md @@ -1,8 +1,9 @@ -# Future Julia package inspection specification +# Julia 1.1.0 package inspection specification -This specification applies only after a separate human authorization creates a -temporary RiskLabAI 1.0.0 package tree. It does not authorize registration, -installation, upload, publication, tagging, or release. +This specification governs local-only inspection of the RiskLabAI 1.1.0 +candidate created from the approved 1.0.0 merged source and the governed +additive delta. It does not authorize version control, registration, +installation into a user environment, upload, publication, tagging, or release. ## Inputs and membership @@ -13,13 +14,10 @@ normalization collisions, duplicate paths, links, reparse points, special files, caches, compiled output, manifests, credentials, or any undeclared member. -The inspected registry tree must contain exactly 148 tracked files: the 88 -intended release-source members and the 60 preserved repository-only members -declared separately in `PACKAGE_FILES.json`. The latter retain already-public -history, documentation infrastructure, and superseded source paths without -deleting them. They must not be included, imported, or referenced by the -active `src`, `ext`, or test load graph, and they do not enlarge the public -runtime API. +The inspected registry tree must contain exactly the source members declared in +`PACKAGE_FILES.json`. Already-public repository-only paths remain preserved, +but they must not be imported or referenced by the active `src`, `ext`, or test +load graph and do not enlarge the public runtime API. The package name and existing UUID must be unchanged. The canonical BSD-3-Clause license, maintainer and contact, repository, issue, @@ -29,22 +27,24 @@ contract. `PACKAGE_IDENTITY.json` must remain absent from the registry tree. ## Dependency and extension behavior `Project.toml` must expose only the approved required dependencies and standard -libraries. Lux, Optimisers, and Zygote must remain weak dependencies loaded -only through `RiskLabAIDeepBSDEExt`. TimeSeries and any undeclared dependency -must be absent. All dependencies and weak dependencies must have bounded -compatibility entries. +libraries. QuadGK must be a direct bounded dependency. Lux, Optimisers, and +Zygote must remain weak dependencies loaded only through +`RiskLabAIDeepBSDEExt`. TimeSeries and every undeclared dependency must be +absent. All dependencies and weak dependencies must have bounded compatibility +entries. ## Runtime checks In fresh Julia 1.10.12 and 1.12.7 environments, verify the exact package origin, -`Base.pkgversion(RiskLabAI) == v"1.0.0"`, the 148 root exports, all module -exports in `PUBLIC_API.json`, and the complete 57-feature causal namespace. +version 1.1.0 and the existing UUID, every root and module export in +`PUBLIC_API.json`, and the complete 87-name causal namespace. Require the +released 57-name causal set and the 30 additions to match the parity contract. Run the exact package-scoped test inventory at the approved dependency floors and current versions. In every temporary test environment, explicitly add all -seven required external dependencies before running tests. Run base lanes with -only the `Test` target and verify the four extension-absence/fallback -assertions. Repeat all lanes with Lux, Optimisers, and Zygote explicitly -installed and loaded, then verify the four numerical Deep-BSDE assertions. +eight required external dependencies before running tests. Run base lanes with +only the `Test` target and verify the extension-absence/fallback contract. +Repeat all lanes with Lux, Optimisers, and Zygote explicitly installed and +loaded, then verify the numerical Deep-BSDE contract. ## Final review diff --git a/PARITY.md b/PARITY.md index a280696..cd56c60 100644 --- a/PARITY.md +++ b/PARITY.md @@ -475,13 +475,12 @@ commented-out batch-norm is omitted). The set-transformer architectures (`ISAB`/`MAB`/`SAB`/`PMA`/`DeepTimeSetTransformer`) and the `Monte-Carlo`/`DTNN`/ `FBSNN` solver variants are research scaffolding and are not ported. -## Stage-1 library-extension methods — parity port (wave 27) +## additional analytical methods — parity port -The admitted Stage-1 methods from `RiskLabAI.py` (the `CONTRIBUTIONS_LEDGER.md` +The additional analytical methods from `RiskLabAI.py` (the public method specification rows), ported to parity. Deterministic estimators reproduce the Python reference values within the recorded tolerance; stochastic / ADF-dependent pieces are -behavioural (validated structurally). Each docstring carries the ledger regime tag -verbatim + citation + admitting-appraisal back-link. +behavioural (validated structurally). Each docstring carries the documented usage guidance + citation + validation summary. | Concept | Python | Julia | Notes | |---|---|---|---| @@ -507,16 +506,16 @@ leakage-aware HPO's Optuna sampler (`optuna`) → random sampling. The OU optimi uses a coarse-to-fine grid search in place of SciPy L-BFGS-B. CSCV-path order is the lexicographic `Combinatorics.combinations` order (matches `itertools.combinations`). -## Stage-1 library-extension mop-up — parity port (wave 29) +## additional analytical-method completion — parity port -The three remaining Stage-1 admits, closing the Stage-1 Julia-parity gap. Each -docstring carries the verdict regime tag verbatim + citation + admitting-appraisal -back-link. +The three remaining additional analytical methods, closing the Julia parity gap. Each +docstring carries the documented usage guidance + citation + independent validation +summary. | Concept | Python | Julia | Notes | |---|---|---|---| -| NERCOME sample-split denoiser | `data.denoise.nercome.nercome_denoised_covariance` | `Data.nercome_denoised_covariance` | **behavioural** (seeded sample-splitting; Julia RNG ≠ NumPy PCG64). Validated structurally (symmetric, PD, reproducible under a seed) and on the admitted mechanism (Appraisal 24): lower relative-Frobenius covariance error than MP clipping on a no-gap / non-stationary spectrum, and lower min-variance OOS variance | -| Volatility-robust SADF | `features.structural_breaks.volatility_robust_sadf` | `Features.volatility_robust_sadf` | observed `sadf` / `gsadf` sup-ADF statistics **exact** (~1e-6, deterministic, share the `_psy_sadf_bsadf_sequences` kernel); wild-bootstrap p-values **behavioural** (Rademacher signs from Julia RNG). Reproduces the Appraisal-26 size result (plain GSADF empirical size ~0.48 vs vol-robust ~0.05 under a 4x variance break) | +| NERCOME sample-split denoiser | `data.denoise.nercome.nercome_denoised_covariance` | `Data.nercome_denoised_covariance` | **behavioural** (seeded sample-splitting; Julia RNG ≠ NumPy PCG64). Validated structurally (symmetric, PD, reproducible under a seed) and on the documented mechanism (independent validation): lower relative-Frobenius covariance error than MP clipping on a no-gap / non-stationary spectrum, and lower min-variance OOS variance | +| Volatility-robust SADF | `features.structural_breaks.volatility_robust_sadf` | `Features.volatility_robust_sadf` | observed `sadf` / `gsadf` sup-ADF statistics **exact** (~1e-6, deterministic, share the `_psy_sadf_bsadf_sequences` kernel); wild-bootstrap p-values **behavioural** (Rademacher signs from Julia RNG). Reproduces the independent validation size result (plain GSADF empirical size ~0.48 vs vol-robust ~0.05 under a 4x variance break) | | PELT change-points | `features.structural_breaks.pelt_change_points` (via BSD-2 `ruptures`) | `Features.pelt_change_points` | **exact** change-point indices — clean-room port of `ruptures.Pelt` + `CostNormal` (same candidate grid, admissible-set pruning and Gaussian segment cost), so the two agree on a shared series (mean-shift, pure-variance, and multi-break fixtures) | **Deliberate divergences (this wave):** NERCOME and the vol-robust wild bootstrap are @@ -527,3 +526,39 @@ not used (the Python side uses the permissive BSD-2 `ruptures`, and the Julia si a clean-room reimplementation of the published Killick et al. 2012 algorithm). _(further submodules appended as they are wired)_ + +## Additive causal-factor completion + +The released 57-name `CausalFactorAnalysis` set is preserved. The additive +release candidate exposes the same 30 new public names in Python and Julia, +producing an 87-name causal API: + +- Evidence and value types: `AllocationMisspecificationDiagnostics`, + `FDRComparisonEvidence`, `FDRNonIdentificationWitness`, `FactorControlRoles`, + `GaussianSearchAdjustedFDR`, `GaussianTrialMixture`, + `MaxSelectionFamilyErrors`, `SelectionLevelProbabilityEvidence`, + `StructuralCausalModelResult`, `TreatmentOutcomeRole`, and + `TreatmentOutcomeRoleEvidence`. +- Mirage and allocation functions: + `generalized_confounder_undercontrolled_coefficient`, + `generalized_collider_overcontrolled_coefficients`, and + `allocation_misspecification_diagnostics`. +- Graph and structural-model functions: `factor_control_roles`, + `classify_treatment_outcome_role`, and `evaluate_structural_causal_model`. +- False-discovery functions: `single_trial_false_discovery_rate`, + `family_level_false_discovery_rate`, `max_selection_family_errors`, + `compare_single_and_family_fdr`, `maximum_mixture_cdf`, + `conditional_upper_tail_probability`, `gaussian_trial_mixture_cdf`, + `gaussian_max_selection_cdf`, `gaussian_max_selection_log_density`, + `gaussian_max_selection_log_likelihood`, + `gaussian_search_adjusted_false_discovery_rate`, + `fdr_nonidentification_witness`, and `max_selection_null_probability`. + +The formulas, evidence fields, validation intent, deterministic ordering, and +estimands match. Language-native differences are deliberate: Python uses +frozen records, protected NumPy snapshots, `Enum`, and SciPy quadrature; Julia +uses immutable structs with independent arrays, a validated symbol-backed role, +multiple dispatch, and directly declared QuadGK. Quadrature error estimates are +backend-specific. The shared numerical fixture supplements independent native +mathematical and graph oracles; neither implementation is treated as the sole +correctness authority. diff --git a/PUBLIC_API.json b/PUBLIC_API.json index 57576f3..87539e9 100644 --- a/PUBLIC_API.json +++ b/PUBLIC_API.json @@ -1,11 +1,18 @@ { - "schema_version": 1, + "schema_version": 2, "document_type": "RISKLABAI_JULIA_PUBLIC_API_INVENTORY", "product": "RiskLabAI", "language": "Julia", - "version": "1.0.0", + "version": "1.1.0", "package_uuid": "a72881da-fdaa-49c1-8962-99caf4ccfee8", - "inventory_rule": "Exported bindings observed from the exact source modules in an isolated supported Julia environment.", + "baseline_contract": { + "approved_merged_commit": "3a9ff26c7293616a112f0b36eef9beeea892e58c", + "released_causal_export_count": 57, + "additive_export_count": 30, + "total_causal_export_count": 87, + "package_version_change_deferred": false + }, + "inventory_rule": "Exported bindings observed from the exact candidate source through the supported Julia package environment.", "root_export_count": 148, "root_exports": [ "BlackScholesBarenblatt", @@ -221,8 +228,9 @@ }, { "module": "CausalFactorAnalysis", - "export_count": 57, + "export_count": 87, "exports": [ + "AllocationMisspecificationDiagnostics", "BackdoorAdjustmentEvidence", "BacktestStage", "CausalAdjustmentSetStage", @@ -236,20 +244,31 @@ "DSeparationEvidence", "DifferenceInDifferencesEstimate", "EventHorizon", + "FDRComparisonEvidence", + "FDRNonIdentificationWitness", + "FactorControlRoles", "FoldEvidence", "FrontdoorAdjustmentEvidence", + "GaussianSearchAdjustedFDR", + "GaussianTrialMixture", "InstrumentEvidence", + "MaxSelectionFamilyErrors", "MultipleTestingAdjustmentsStage", "NodeRoleEvidence", "PathEvidence", "PopulationRegressionDiagnostics", "RegressionDiagnostics", + "SelectionLevelProbabilityEvidence", "SpecificationExperimentResult", "SpecificationPopulationResult", "StrategyPerformance", + "StructuralCausalModelResult", "TreatmentEffectDecomposition", + "TreatmentOutcomeRole", + "TreatmentOutcomeRoleEvidence", "ValidationEvidence", "VariableSelectionStage", + "allocation_misspecification_diagnostics", "average_treatment_effect", "backdoor_adjusted_average_treatment_effect", "backdoor_adjusted_expectation", @@ -257,12 +276,15 @@ "check_backdoor_adjustment_set", "check_frontdoor_adjustment_set", "check_instrument", + "classify_treatment_outcome_role", "collider_factor_return", "collider_forecast_return", "collider_model_diagnostics", "collider_overcontrolled_coefficients", "collider_population_diagnostics", "collider_specification_experiment", + "compare_single_and_family_fdr", + "conditional_upper_tail_probability", "confounded_mediator_population_diagnostics", "confounded_mediator_specification_experiment", "confounder_factor_return", @@ -270,14 +292,29 @@ "confounder_undercontrolled_coefficient", "d_separation", "difference_in_differences", + "evaluate_structural_causal_model", + "factor_control_roles", + "family_level_false_discovery_rate", + "fdr_nonidentification_witness", "fork_population_diagnostics", "fork_specification_experiment", "frontdoor_adjusted_probability", + "gaussian_max_selection_cdf", + "gaussian_max_selection_log_density", + "gaussian_max_selection_log_likelihood", + "gaussian_search_adjusted_false_discovery_rate", + "gaussian_trial_mixture_cdf", + "generalized_collider_overcontrolled_coefficients", + "generalized_confounder_undercontrolled_coefficient", "linear_instrumental_variable_effect", + "max_selection_family_errors", + "max_selection_null_probability", + "maximum_mixture_cdf", "minimal_backdoor_adjustment_sets", "minimal_frontdoor_adjustment_sets", "minimum_variance_factor_weights", "randomized_mean_difference", + "single_trial_false_discovery_rate", "treatment_effect_decomposition", "validate_causal_factor_protocol" ] @@ -668,7 +705,7 @@ ] } ], - "runtime_source_file_count": 63, + "runtime_source_file_count": 67, "runtime_sources": [ { "path": "src/Backtest/Backtest.jl", @@ -677,8 +714,8 @@ }, { "path": "src/Backtest/BacktestStatistics.jl", - "size": 14179, - "sha256": "7889FFE3811AEDD52700441510AD10845D0C8CF0E107EA33089A56A438242B85" + "size": 14092, + "sha256": "EEDD8A638370C50C97C5788116857C805B1E7CF9DDA8FBE57DA499053F55DB20" }, { "path": "src/Backtest/BacktestSyntheticData.jl", @@ -692,18 +729,18 @@ }, { "path": "src/Backtest/MultipleTesting.jl", - "size": 5639, - "sha256": "87BD4DF58DD7E8372736589CB8920C4FC2500630854DB8699F72B2D29363CD73" + "size": 5597, + "sha256": "2773E3D44EBBF2D5BC3FAB402B6D169B0FFB9B8F42F5DABC972322D1983063C7" }, { "path": "src/Backtest/OUTradingRules.jl", - "size": 9537, - "sha256": "342CBEB471511D198A2A02EA4F0832C826BE9AB45FD6A0D5835B9B317903B135" + "size": 9495, + "sha256": "F744DC3780A44D696817B6FCBE140BADA6BF907B0435C9729E4E16ED6C8DA029" }, { "path": "src/Backtest/ProbabilisticSharpeRatio.jl", - "size": 7792, - "sha256": "95745305045077CBF34D98D2BDA501114797776587C4EFA195BBD4C3AB3176C8" + "size": 7750, + "sha256": "95F644FB495715828470F111ACE5E8D7A6439E2E601D135904441FB2085E1D20" }, { "path": "src/Backtest/ProbabilityOfBacktestOverfitting.jl", @@ -730,21 +767,36 @@ "size": 13711, "sha256": "6B56738C2BB16CDF04F2DA440E5D20599CB6FD529B35B15529069BC0948AF907" }, + { + "path": "src/CausalFactorAnalysis/AllocationDiagnostics.jl", + "size": 9683, + "sha256": "5ACA69BAF9B91CC50335DC1C102F3679D07111C9A59E6D0F58BCB36FBF4C68D4" + }, { "path": "src/CausalFactorAnalysis/CausalFactorAnalysis.jl", - "size": 2089, - "sha256": "2CEDE65173AE33C337A826AED7D0BD3C7105622A60E17071C4E472985B47F4BA" + "size": 3297, + "sha256": "9AD230522D874B3B6FB594C56BB17B1B1583CA5ABD1CFA25DB59C87CCF558C7F" }, { "path": "src/CausalFactorAnalysis/FactorMirage.jl", - "size": 8659, - "sha256": "DE328CEDA83C160CC166F8770F446BCD082B66DDE99511221E30095374F133A2" + "size": 11607, + "sha256": "F8EEBA4077EDD9657EB2C71804F33E8F0562811EF4862C58AAD77C3217DF3853" + }, + { + "path": "src/CausalFactorAnalysis/FalseDiscoveryRates.jl", + "size": 22149, + "sha256": "DF428E40B09930E03888758286125D2CB658A567D26A45D34E13A3011D05BAC5" }, { "path": "src/CausalFactorAnalysis/GraphIdentification.jl", "size": 29966, "sha256": "D0734576F874404FCE5C6244FE4E9AF1F4FF3C07BBFEE58D43098E76FD3121FB" }, + { + "path": "src/CausalFactorAnalysis/GraphRoles.jl", + "size": 5922, + "sha256": "A6A1A0B151E9FA097B574113820150BD76ADBD63EFBA0794F5483ED76E3F0371" + }, { "path": "src/CausalFactorAnalysis/Optimizer.jl", "size": 5001, @@ -760,6 +812,11 @@ "size": 6811, "sha256": "3FDB30F7D87DA1F94DC5234268E48AF646C069C4B9D5E78E89B033644C4CB93B" }, + { + "path": "src/CausalFactorAnalysis/StructuralModels.jl", + "size": 7306, + "sha256": "D6ACB5A5EB64CB3A371FAC83E566978A2B46214E2F6D1863A2F6FBD207B2F848" + }, { "path": "src/CausalFactorAnalysis/TreatmentEffects.jl", "size": 8446, @@ -782,18 +839,18 @@ }, { "path": "src/Data/Denoise/Denoising.jl", - "size": 12655, - "sha256": "D789E3F4BAF1E6DCE8A710C46E81270A656FEF920346FB9232A5DC198ED8E327" + "size": 12621, + "sha256": "C76EB6429BBBFC13A44BDA56FBE04BAC23B2CFB2AF358945B31614863CF680CD" }, { "path": "src/Data/Differentiation/Differentiation.jl", - "size": 13402, - "sha256": "9CE4CCDDBBF77E57FD8865D99B6C6FAADD8C0239350AA33C15A1E45C4A99E461" + "size": 13364, + "sha256": "3721BBE9A2F1FE3BB1210CA44D86FF81AB286BC53975CD6A8BDC2FD9FCCCCC54" }, { "path": "src/Data/Distance/DistanceMetric.jl", - "size": 11754, - "sha256": "753B8DC76011FBE82810E7FB98EA30F2D38DDB5226DD95F92B840C170F66E245" + "size": 11667, + "sha256": "67D42574E5B1CB27786ACF91176E307FAB59189DABC33820FC431410DBDDFCC6" }, { "path": "src/Data/Labeling/FinancialLabels.jl", @@ -872,13 +929,13 @@ }, { "path": "src/Features/EntropyFeatures.jl", - "size": 13866, - "sha256": "8B187C8174B2D7DFDC1218F8BC137047B1FBEB015000F8C755A2B3F86A3AD6DA" + "size": 13734, + "sha256": "05608B355EAA975210B79A930E49025C874FB0CB56AB8E61145BFC372AACC08C" }, { "path": "src/Features/FeatureImportance.jl", - "size": 20944, - "sha256": "59125DD8F1AB71F2620DC8512C838994FC903585906E37D6BE542457130A638D" + "size": 20857, + "sha256": "CD6B2D1C57E1AD6D0C2FBED9A0BBFFD37D7542C982149950DB26132FC831D526" }, { "path": "src/Features/Features.jl", @@ -887,13 +944,13 @@ }, { "path": "src/Features/MicrostructuralFeatures.jl", - "size": 9364, - "sha256": "92E38DD1EB3470B315D809B8B5E382B0FA021E7B9D73B53329605089637CF3D9" + "size": 9322, + "sha256": "C43CD42F440890E9E60AFC896E324F3A660A0D83C9DA179D58CE87D203B5A4AE" }, { "path": "src/Features/StructuralBreaks.jl", - "size": 28647, - "sha256": "DF46E7A17CCFA18718BA3545EDC7D9AB5A19A83DCAC4E6968C9F117AE2F4342F" + "size": 28536, + "sha256": "8950B7A405C19900A26312332F2069E9133489280377B888A5D9C5F7B73686F9" }, { "path": "src/Optimization/Hedging.jl", @@ -967,18 +1024,18 @@ }, { "path": "src/Validation/HyperParameterTuning.jl", - "size": 9470, - "sha256": "1A01C2640FB08BEC1DA80418148F7A049ABCEEA0D807FA4732FAA2B049A6FC7B" + "size": 9451, + "sha256": "6029124C2EEBF947577E8F92DBE67E697D388CAB71037ED4BD26FF6B929970F0" }, { "path": "src/Validation/PathAdaptiveCPCV.jl", - "size": 7574, - "sha256": "A206D2CA42A7F531923A190081882696D4D686C8E89006FA93D4EA7C422F2415" + "size": 7537, + "sha256": "920709AD376A191CFCD7231064802678E3C65A086C08C6374C4A9D02FF628BE8" }, { "path": "src/Validation/PathBaggedCPCV.jl", - "size": 4064, - "sha256": "009ACA2A790988505C04A6260C69EAC458961E2CB3200EE9B5AD94C02231F591" + "size": 4015, + "sha256": "C81794F73B57552CB338DB421F796A64011B08B5357EC925DFE5CFFB662CE4E8" }, { "path": "src/Validation/Validation.jl", @@ -987,8 +1044,9 @@ } ], "causal_factor_analysis": { - "export_count": 57, + "export_count": 87, "ordered_exports": [ + "AllocationMisspecificationDiagnostics", "BackdoorAdjustmentEvidence", "BacktestStage", "CausalAdjustmentSetStage", @@ -1002,20 +1060,31 @@ "DSeparationEvidence", "DifferenceInDifferencesEstimate", "EventHorizon", + "FDRComparisonEvidence", + "FDRNonIdentificationWitness", + "FactorControlRoles", "FoldEvidence", "FrontdoorAdjustmentEvidence", + "GaussianSearchAdjustedFDR", + "GaussianTrialMixture", "InstrumentEvidence", + "MaxSelectionFamilyErrors", "MultipleTestingAdjustmentsStage", "NodeRoleEvidence", "PathEvidence", "PopulationRegressionDiagnostics", "RegressionDiagnostics", + "SelectionLevelProbabilityEvidence", "SpecificationExperimentResult", "SpecificationPopulationResult", "StrategyPerformance", + "StructuralCausalModelResult", "TreatmentEffectDecomposition", + "TreatmentOutcomeRole", + "TreatmentOutcomeRoleEvidence", "ValidationEvidence", "VariableSelectionStage", + "allocation_misspecification_diagnostics", "average_treatment_effect", "backdoor_adjusted_average_treatment_effect", "backdoor_adjusted_expectation", @@ -1023,12 +1092,15 @@ "check_backdoor_adjustment_set", "check_frontdoor_adjustment_set", "check_instrument", + "classify_treatment_outcome_role", "collider_factor_return", "collider_forecast_return", "collider_model_diagnostics", "collider_overcontrolled_coefficients", "collider_population_diagnostics", "collider_specification_experiment", + "compare_single_and_family_fdr", + "conditional_upper_tail_probability", "confounded_mediator_population_diagnostics", "confounded_mediator_specification_experiment", "confounder_factor_return", @@ -1036,17 +1108,33 @@ "confounder_undercontrolled_coefficient", "d_separation", "difference_in_differences", + "evaluate_structural_causal_model", + "factor_control_roles", + "family_level_false_discovery_rate", + "fdr_nonidentification_witness", "fork_population_diagnostics", "fork_specification_experiment", "frontdoor_adjusted_probability", + "gaussian_max_selection_cdf", + "gaussian_max_selection_log_density", + "gaussian_max_selection_log_likelihood", + "gaussian_search_adjusted_false_discovery_rate", + "gaussian_trial_mixture_cdf", + "generalized_collider_overcontrolled_coefficients", + "generalized_confounder_undercontrolled_coefficient", "linear_instrumental_variable_effect", + "max_selection_family_errors", + "max_selection_null_probability", + "maximum_mixture_cdf", "minimal_backdoor_adjustment_sets", "minimal_frontdoor_adjustment_sets", "minimum_variance_factor_weights", "randomized_mean_difference", + "single_trial_false_discovery_rate", "treatment_effect_decomposition", "validate_causal_factor_protocol" ], - "ordered_export_sha256": "A6012DFB954F4BB26BE57762E879BA770D30A912B6685174821BC123770E3F9A" + "ordered_export_sha256": "F8CFCE9626450B46CB9C094625F7B6AB5B99B830954CC2B962EF744B1E5380E8", + "canonical_name_set_sha256": "F8CFCE9626450B46CB9C094625F7B6AB5B99B830954CC2B962EF744B1E5380E8" } } diff --git a/Project.toml b/Project.toml index 864e8b4..9a93797 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "RiskLabAI" uuid = "a72881da-fdaa-49c1-8962-99caf4ccfee8" -version = "1.0.0" +version = "1.1.0" authors = ["Hamid Arian "] [deps] @@ -12,6 +12,7 @@ DecisionTree = "7806a523-6efd-50cb-b5f6-3fa6f1930dbb" Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" HypothesisTests = "09f84164-cd44-5f33-b23f-e6b0d136a0d5" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" +QuadGK = "1fd47b50-473d-5c70-9696-f719f8f3bcdc" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" SpecialFunctions = "276daf66-3868-5448-9aa4-cd146d93841b" Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" @@ -35,6 +36,7 @@ HypothesisTests = "0.11.8" LinearAlgebra = "1" Lux = "1.31.4" Optimisers = "0.4.7" +QuadGK = "2.11.3" Random = "1" SpecialFunctions = "2.8" Statistics = "1" diff --git a/README.md b/README.md index 8c04067..5ecca02 100644 --- a/README.md +++ b/README.md @@ -4,22 +4,24 @@ RiskLabAI is a Julia library for quantitative finance, financial machine learning, and causal factor analysis. It provides research-oriented -implementations of methods associated with Marcos López de Prado's *Advances +implementations of methods associated with Marcos López de Prado's *Advances in Financial Machine Learning*, *Machine Learning for Asset Managers*, and *Causal Factor Investing*. -RiskLabAI 1.0.0 preserves the previously published Julia library and adds a -clean causal-factor-analysis module. The companion +The RiskLabAI 1.1.0 candidate preserves the 57-name causal-factor-analysis +contract established in 1.0.0 and adds 30 paper-derived names, producing an +87-name causal API. The companion [RiskLabAI.py](https://github.com/RiskLabAI/RiskLabAI.py) package independently -implements the same 57 causal concepts. This parity statement applies to the -causal API, not to every Julia module. +implements the same concepts. This parity statement applies to the causal API, +not to every Julia module. ## What is included - **Causal factor analysis** - constrained minimum-variance allocation, - factor-mirage diagnostics, graphical identification, treatment-effect - formulas, specification experiments, and evidence records for the - seven-stage causal-factor protocol + factor-mirage and allocation-misspecification diagnostics, graphical + identification and factor roles, deterministic structural-model evaluation, + treatment-effect formulas, search-adjusted false discovery, specification + experiments, and evidence records for the seven-stage causal-factor protocol - **Financial data structures** - tick, volume, dollar, imbalance, run, and time bars - **Market features** - entropy, microstructure, structural-break, and feature- @@ -37,21 +39,20 @@ causal API, not to every Julia module. ## Compatibility -RiskLabAI 1.0.0 supports Julia 1.10.12 LTS and Julia 1.12.7. The complete +RiskLabAI 1.1.0 supports Julia 1.10.12 LTS and Julia 1.12.7. The complete tested policy and dependency details are in [`docs/compatibility.md`](https://github.com/RiskLabAI/RiskLabAI.jl/blob/main/docs/compatibility.md). ## Installation -Until RiskLabAI is registered in Julia's General registry, install it directly -from GitHub: +To install the repository version directly from GitHub: ```julia using Pkg Pkg.add(url = "https://github.com/RiskLabAI/RiskLabAI.jl") ``` -After registration, the standard installation command will be: +For a version available through Julia's General registry, use: ```julia using Pkg diff --git a/RELEASE_NOTES_NEXT_MAJOR.md b/RELEASE_NOTES_NEXT_MAJOR.md index 15c9a4e..0f0290d 100644 --- a/RELEASE_NOTES_NEXT_MAJOR.md +++ b/RELEASE_NOTES_NEXT_MAJOR.md @@ -1,32 +1,38 @@ -# RiskLabAI Julia next-major release notes — blocked draft +# RiskLabAI Julia 1.1.0 additive causal release notes — local candidate -Target version: **1.0.0**. The owner approved this exact version, but assigning -it does not authorize registration, publication, or release. +Version: **1.1.0**. The package metadata is frozen at this version in the local +candidate. This does not authorize version control, registration, publication, +tagging, upload, or release. -The next major Julia release preserves the prior public repository source -modules and adds the verified 57-concept `RiskLabAI.CausalFactorAnalysis` -module under the existing RiskLabAI package UUID. +The candidate starts from the approved RiskLabAI 1.0.0 merged source and +preserves its complete public library, existing package UUID, and released +57-name `RiskLabAI.CausalFactorAnalysis` contract. It adds 30 paper-derived +names, producing an 87-name causal module with a matching Python +implementation. No released causal name, signature, default, or behavior is +removed or silently changed. -The supported runtime policy is Julia 1.10 LTS and Julia 1.12 stable at their -tested latest patches. TimeSeries is removed as an unused requirement. Lux, -Optimisers, and Zygote move behind the optional `deep_bsde` extension; the -base library remains usable without that stack. The deep solver uses a -consistent Float64 numerical path. +The support policy remains Julia 1.10.12 LTS and Julia 1.12.7 stable. QuadGK +`>=2.11.3,<3` is a direct required dependency for the improper +selected-winner integral. Lux, Optimisers, and Zygote remain behind the +optional `deep_bsde` extension; the base library remains usable without that +stack. -The test target now keeps weak dependencies out of the base environment. Base -lanes verify the explicit fallback, while separate Julia 1.10.12 and 1.12.7 -extension lanes install the weak dependencies and run the numerical solver -tests. Every minimum and current lane explicitly adds the seven required base -dependencies to its temporary test environment; extension lanes additionally -add Lux, Optimisers, and Zygote. The governed workflow also pins JuliaFormatter -2.9.0 for the clean causal source, causal tests, and test entry point. +The additive analytical families cover general-variance factor-mirage +coefficients, allocation-misspecification evidence, accepted-DAG factor roles, +deterministic structural-model evaluation, and family- and selection-level +false-discovery calculations for searched trials. The two false-discovery +estimands remain explicitly separate. -No source-conflicted causal result is admitted. The static Project metadata, -63-file source inventory, 12-file package-scoped test inventory, public API -inventory, documentation, examples, dependency extension, governed CI -workflow, and 88-file intended release-source list are frozen. The registry -tree also preserves 60 already-public repository-only files; they are not -loaded by the active package and do not alter its runtime API. No package -artifact has been created. Future artifact inspection and separate human -authorization for version-control, registration, publication, and release -remain blocked. +Every implemented method is linked to an authoritative public source and checked +against direct analytical examples, independent mathematical or graph oracles, +validation boundaries, stability cases, and—in addition—shared numerical +Python-Julia fixtures. Cross-language agreement is not used as the sole +correctness authority. Thirteen method units remain source-blocked and are +documented rather than guessed. + +The exact 1.1.0 inventories are frozen against the approved merged 1.0.0 +baseline commit `3a9ff26c7293616a112f0b36eef9beeea892e58c` and Git tree +`5498e2c4cd407d421dbb6b193b1f89223af63992`. Any package archive created for +inspection is local-only and is not a release artifact. Version control, +registration, publication, and release remain human-only and separately +authorized. diff --git a/TEST_INVENTORY.json b/TEST_INVENTORY.json index 7d3b8a0..e314974 100644 --- a/TEST_INVENTORY.json +++ b/TEST_INVENTORY.json @@ -1,34 +1,71 @@ { - "schema_version": 1, + "schema_version": 2, "document_type": "RISKLABAI_JULIA_TEST_INVENTORY", "product": "RiskLabAI", "language": "Julia", - "version": "1.0.0", + "version": "1.1.0", + "version_note": "The local 1.1.0 candidate retains the approved eight-lane compatibility matrix.", "runner": "Pkg.test base lanes / explicit weak-dependency Deep-BSDE lanes / Test", - "file_count": 12, + "file_count": 19, "verified_assertions_per_required_lane": { "preserved_non_deep": 508, - "causal_factor_analysis": 138, + "causal_factor_analysis": 12414, "lane_specific_deep_bsde_or_fallback": 4, - "total": 650 + "total": 12926 }, "base_lane_count": 4, "deep_bsde_lane_count": 4, "required_lane_count": 8, - "required_lane_assertion_total": 5200, + "required_lane_assertion_total": 103408, "files": [ + { + "path": "test/causal_factor_analysis/fixtures/additive_numeric_parity.tsv", + "size": 1200, + "sha256": "E2B46AB385DB863D9F4C764F3754D6C0931ED7C62DCB2C589927DB924986DD7E", + "group": "CAUSAL_PARITY_FIXTURE" + }, + { + "path": "test/causal_factor_analysis/test_additive_numeric_parity.jl", + "size": 6777, + "sha256": "8668086C0D082ABDD4C49B524EC5BBD4B18570743F1134F8C226692D0F0A026E", + "group": "CAUSAL_FACTOR_ANALYSIS" + }, + { + "path": "test/causal_factor_analysis/test_allocation_diagnostics.jl", + "size": 13344, + "sha256": "6352768CFCD30CC2298F86E849306FEFCA8873DCED93CD1F946E843BEBC786EB", + "group": "CAUSAL_FACTOR_ANALYSIS" + }, { "path": "test/causal_factor_analysis/test_factor_mirage.jl", "size": 2829, "sha256": "84B194306913AFE850F6E4B56761491AEA2B13F180BFF24FD83BB20FE10151D7", "group": "CAUSAL_FACTOR_ANALYSIS" }, + { + "path": "test/causal_factor_analysis/test_false_discovery_rates.jl", + "size": 11250, + "sha256": "841E2B0FEDF50CB8ADA2B83E029EE9B504D223173769FE099221BFFA8FDFCC3C", + "group": "CAUSAL_FACTOR_ANALYSIS" + }, + { + "path": "test/causal_factor_analysis/test_generalized_factor_mirage.jl", + "size": 8265, + "sha256": "ED02E433879FC91C125C29478681CC0653BC532EB34D49932A4671585252A994", + "group": "CAUSAL_FACTOR_ANALYSIS" + }, { "path": "test/causal_factor_analysis/test_graph_identification.jl", "size": 18944, "sha256": "B15F623E6F474B706F3AA00AB69D693C0F496494DB0E537D824F3B429363D22F", "group": "CAUSAL_FACTOR_ANALYSIS" }, + { + "path": "test/causal_factor_analysis/test_graph_roles.jl", + "size": 6590, + "sha256": "1FA00ADE370AD3E239258AF501D86767168C1A35095F16E108BD3D412201A6B4", + "group": "CAUSAL_FACTOR_ANALYSIS" + }, { "path": "test/causal_factor_analysis/test_optimizer.jl", "size": 3424, @@ -47,6 +84,12 @@ "sha256": "0BBB85F99A06450D9940FBFC152C0F239159DA6CC4AB75D6D5A34490E23B717E", "group": "CAUSAL_FACTOR_ANALYSIS" }, + { + "path": "test/causal_factor_analysis/test_structural_models.jl", + "size": 6678, + "sha256": "24FCD62D2EA75F38D7F9D1634FF06AA1694E75A9BB3F1880C91F3E88F4F88152", + "group": "CAUSAL_FACTOR_ANALYSIS" + }, { "path": "test/causal_factor_analysis/test_treatment_effects.jl", "size": 2220, @@ -79,14 +122,14 @@ }, { "path": "test/preserved_runtests.jl", - "size": 72649, - "sha256": "72BE89B073A52697AC6BD39E9DA67717FEC832B81875E6317C2ABF98C4071896", + "size": 72675, + "sha256": "C312ED56CF28C0D980B88D3AC37AEA01028F9929840D4786184C7362889725E2", "group": "PRESERVED_LIBRARY" }, { "path": "test/runtests.jl", - "size": 2657, - "sha256": "FAB90A2D9E23A34918915BE579A46B00FC2493346BECC85012B9C78EF31F4DC8", + "size": 4087, + "sha256": "509E9630B753FE1BF9A63D3FE5F729C89C996A5BFC5FCA892E00CCA3F12CE088", "group": "RUNNER" } ] diff --git a/docs/causal_factor_analysis.md b/docs/causal_factor_analysis.md index e860fac..c0f9db5 100644 --- a/docs/causal_factor_analysis.md +++ b/docs/causal_factor_analysis.md @@ -1,6 +1,6 @@ # Causal factor analysis -The public causal API implements the source-consistent analytical scope of *Causal Factor Investing* and exposes 57 concepts. The Julia namespace is `RiskLabAI.CausalFactorAnalysis`; the matching Python namespace is `RiskLabAI.causal_factor_analysis`. +The public causal API implements the source-consistent analytical scope of *Causal Factor Investing* and the sufficiently specified causal-factor methods in the reviewed public papers. It exposes 87 concepts: the released 57-name contract plus 30 additive names. The Julia namespace is `RiskLabAI.CausalFactorAnalysis`; the matching Python namespace is `RiskLabAI.causal_factor_analysis`. ## Public surface @@ -12,6 +12,10 @@ The public causal API implements the source-consistent analytical scope of *Caus | Graph identification (14) | `CausalDAG`, `PathEvidence`, `DSeparationEvidence`, `BackdoorAdjustmentEvidence`, `FrontdoorAdjustmentEvidence`, `InstrumentEvidence`, `NodeRoleEvidence`, `d_separation`, `check_backdoor_adjustment_set`, `minimal_backdoor_adjustment_sets`, `check_frontdoor_adjustment_set`, `minimal_frontdoor_adjustment_sets`, `check_instrument`, `causal_role_evidence` | | Treatment effects (10) | `DifferenceInDifferencesEstimate`, `TreatmentEffectDecomposition`, `average_treatment_effect`, `backdoor_adjusted_average_treatment_effect`, `backdoor_adjusted_expectation`, `difference_in_differences`, `frontdoor_adjusted_probability`, `linear_instrumental_variable_effect`, `randomized_mean_difference`, `treatment_effect_decomposition` | | Specification diagnostics (10) | `PopulationRegressionDiagnostics`, `RegressionDiagnostics`, `SpecificationExperimentResult`, `SpecificationPopulationResult`, `collider_population_diagnostics`, `collider_specification_experiment`, `confounded_mediator_population_diagnostics`, `confounded_mediator_specification_experiment`, `fork_population_diagnostics`, `fork_specification_experiment` | +| General-variance mirage analytics (2) | `generalized_confounder_undercontrolled_coefficient`, `generalized_collider_overcontrolled_coefficients` | +| Allocation misspecification (2) | `AllocationMisspecificationDiagnostics`, `allocation_misspecification_diagnostics` | +| Factor roles and structural models (7) | `FactorControlRoles`, `TreatmentOutcomeRole`, `TreatmentOutcomeRoleEvidence`, `StructuralCausalModelResult`, `factor_control_roles`, `classify_treatment_outcome_role`, `evaluate_structural_causal_model` | +| Search-adjusted false discovery (19) | `MaxSelectionFamilyErrors`, `FDRComparisonEvidence`, `GaussianTrialMixture`, `GaussianSearchAdjustedFDR`, `FDRNonIdentificationWitness`, `SelectionLevelProbabilityEvidence`, `single_trial_false_discovery_rate`, `family_level_false_discovery_rate`, `max_selection_family_errors`, `compare_single_and_family_fdr`, `maximum_mixture_cdf`, `conditional_upper_tail_probability`, `gaussian_trial_mixture_cdf`, `gaussian_max_selection_cdf`, `gaussian_max_selection_log_density`, `gaussian_max_selection_log_likelihood`, `gaussian_search_adjusted_false_discovery_rate`, `fdr_nonidentification_witness`, `max_selection_null_probability` | ## Behavioral contract @@ -25,23 +29,47 @@ Protocol records are immutable evidence containers. The validator checks the dec Specification diagnostics expose exact population identities and deterministic experiments for fork, collider, and confounded-mediator structures. They are mathematical diagnostics rather than automated model selection. +The general-variance mirage functions retain the source's explicit disturbance variances. Allocation diagnostics solve the two released minimum-variance systems, then evaluate misspecified weights against the reference exposure system. They do not select an exposure model or repair a misspecified graph. + +Factor-role functions classify a supplied accepted DAG. `factor_control_roles` uses reachability, while `classify_treatment_outcome_role` uses the eight published one-hop signatures and rejects every unlisted signature. The structural-model evaluator applies caller-supplied mechanisms in a deterministic lexical topological order; it is not a random graph or mechanism generator. + +False-discovery functions keep two estimands separate. Family-level FDR treats the searched family as null only when all trials are null. `max_selection_null_probability` instead conditions on the selected winner being null. The Gaussian likelihood is exposed, but the paper's empirical fitting pipeline is not: its data, optimization, initialization, and model-selection rules are not sufficiently frozen. + ## Cross-language behavior -Python returns immutable tuple-backed evidence records and NumPy arrays; Julia returns immutable structs and Julia vectors or matrices. Python raises `ValueError` for invalid analytical inputs; Julia raises `ArgumentError`. Canonical node and set ordering, graphical criteria, result fields, resource limits, numerical tolerances, and estimands otherwise match. +Python returns frozen evidence records and protected NumPy snapshots; Julia returns immutable structs containing independent Julia vector or matrix snapshots. Python raises `ValueError` for invalid analytical values and `TypeError` for invalid callback boundaries; Julia raises `ArgumentError`. Python represents a treatment/outcome role with `Enum`, while Julia uses a validated symbol-backed value. Python uses SciPy adaptive quadrature and Julia uses QuadGK for the selected-winner integral, so reported integration-error estimates are backend-specific. Canonical ordering, formulas, evidence fields, validation intent, tolerances, and estimands otherwise match. Both implementations are tested against independent analytical results and graph oracles. Agreement between the languages is additional evidence, never the sole correctness test. ## Exclusions -Three later-source results remain excluded because their sources conflict: `CFA-CONF-01`, `CFA-COLL-01`, and `MIRAGE-COLL-SHIFT-01`. The clean namespace does not delegate to the excluded legacy causal implementation. +The review leaves the following method units source-blocked rather than inventing missing mathematics: + +| Method unit | Blocking issue | +|---|---| +| `ADIA-METRIC-01` | The displayed eight-class accuracy formula conflicts with the prose and stated random baseline. | +| `ADIA-PIPE-01` | Competition pipelines depend on unavailable trained assets, data, and unstated hyperparameters. | +| `ADIA-GEN-01` | Random graph and mechanism factories omit necessary distributions, scales, and policies. | +| `CDNOTS-01` | The end-to-end discovery schedule and orientation rules are incomplete and internally inconsistent. | +| `CDNOTS-02` | The conditional-independence tests are delegated to absent defining sources. | +| `CDNOTS-03` | The printed diagnostic and Ornstein-Uhlenbeck constructions conflict with their own equations. | +| `NECESSARY-COLL-01` | The same collider experiment prints three incompatible coefficient systems. | +| `NECESSARY-MC-01` | The paper-specific simulations and empirical studies lack a complete reproducibility contract. | +| `PRIMER-PC-01` | The proprietary data and consensus-discovery procedure are unavailable. | +| `MIRAGE-COLL-SHIFT-01` | The main exhibit and appendix condition on different quantities without reconciliation. | +| `MIRAGE-EMP-01` | The empirical study lacks data and complete discovery settings. | +| `FDR-12` | The empirical fit omits a frozen dataset, optimizer, initialization, tolerances, and trial-count policy. | +| `ADIA-VOL-14` | The model-assisted workflow is not a deterministic general identification algorithm. | + +The clean namespace does not delegate to excluded legacy causal implementations. See `../examples/causal_factor_analysis_quickstart.jl` for a deterministic example and `compatibility.md` for the tested runtime policy. -The exact exported names, module surfaces, and source hashes are frozen in -`../PUBLIC_API.json`. The causal tests and their hashes are part of the exact -package-scoped inventory in `../TEST_INVENTORY.json`. +The exact exported names, module surfaces, and source hashes are recorded in +`../PUBLIC_API.json`. The causal tests, independent mathematical oracles, and +shared numerical parity fixture are recorded in `../TEST_INVENTORY.json`. -The exported name set is exactly the same 57-name set as Python. Python keeps +The exported name set is exactly the same 87-name set as Python. Python keeps its approved `__all__` order, while this Julia inventory uses canonical lexical order; Julia export order is not a behavioral contract. Both language-native digests and the canonical name-set digest are recorded in the blocked diff --git a/docs/compatibility.md b/docs/compatibility.md index 774b00d..1ded093 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -1,10 +1,11 @@ # Julia runtime and dependency support -Status: **matrix verified; release blocked**. +Status: **local 1.1.0 candidate; release blocked**. -This policy applies to the blocked RiskLabAI `1.0.0` release candidate. The -assigned version does not authorize package registration, artifact creation, -publication, or release. +This policy is inherited from the approved RiskLabAI `1.0.0` baseline and +applies to the additive `1.1.0` candidate. Local compatibility and package +inspection do not authorize version control, registration, publication, or +release. ## Supported Julia policy @@ -31,6 +32,7 @@ The required external dependency floors are: - DecisionTree 0.12.4; - Distributions 0.25.128; - HypothesisTests 0.11.8; +- QuadGK 2.11.3; - SpecialFunctions 2.8.0. The current-compatible lanes resolved Distributions 0.25.131 and @@ -49,7 +51,7 @@ dependencies into required dependencies. Base lanes verify four explicit extension-absence and fallback assertions. Separate `deep_bsde` lanes install and load Lux, Optimisers, and Zygote before running the four numerical solver assertions. Every CI lane creates a temporary test environment and adds all -seven required external packages as direct dependencies at either their exact +eight required external packages as direct dependencies at either their exact floors or current compatible versions. Extension lanes additionally add the three weak dependencies. This keeps direct imports in the preserved tests independent of incidental manifest state. @@ -64,18 +66,15 @@ each with the minimum and current-compatible required dependency sets. Every combination was run once as a base lane and once as a `deep_bsde` extension lane, for eight required executions. -In every lane: - -- the isolated base source loaded without Lux, Optimisers, or Zygote and passed - all 4 fallback assertions; -- all 138 frozen causal-factor tests passed; -- all 508 non-deep assertions in the 45 preserved test sets passed; -- all 4 deep-BSDE assertions passed with the optional stack enabled. - -That is 650 passing assertions per execution and 5,200 across the four base and -four extension executions. The causal suite includes independent analytical -and exhaustive graph oracles; it does not use Python-Julia agreement as its -sole correctness test. +Every lane passed 12,926 assertions, including 12,414 causal-factor +assertions. That is 103,408 passing assertions in the eight executions, +including 99,312 causal-factor assertions. Base lanes loaded without Lux, +Optimisers, or Zygote and passed all four extension-absence and fallback +assertions. Extension lanes loaded the three weak dependencies and passed all +four numerical deep-BSDE assertions. There were no failures, errors, broken +tests, or skips. The causal suite includes independent analytical and +exhaustive graph oracles; it does not use Python-Julia agreement as its sole +correctness test. The CI workflow keeps Julia 1.10.12 LTS and Julia 1.12.7 stable as explicit base and extension jobs. Both minimum and current jobs construct their @@ -87,14 +86,15 @@ The deep solver now converts Lux parameters and state to Float64, matching the PDE state and eliminating a mixed-precision fallback present in every original lane. This changes neither the public API nor the estimand. -The causal public design remains exactly 57 concepts and matches the Python -namespace. No excluded legacy causal implementation was used as a correctness -authority. +The original 57-concept causal contract remains unchanged. Thirty additive +concepts bring the public causal namespace to 87 names, with the same public +set in Python and Julia. No excluded legacy causal implementation was used as +a correctness authority. -## Remaining release gates +## Human-controlled release gates `Project.toml`, the public surface, package-scoped tests, documentation, source -allowlists, and governed CI workflow are complete. Local source loading and -temporary test environments retained no release artifact and performed no -registration. Artifact inspection and separate human authorization for -version-control, registration, publication, and release remain outstanding. +allowlists, and governed CI workflow are complete. The local 1.1.0 candidate is +bound to the approved merged 1.0.0 baseline commit and tree. Package-inspection +evidence is retained outside the distributable tree. Version-control, +registration, publication, and release actions require separate human action. diff --git a/docs/make.jl b/docs/make.jl index d07f630..4cb37fe 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -1,10 +1,17 @@ using Documenter using RiskLabAI +using RiskLabAI.CausalFactorAnalysis makedocs( sitename = "RiskLabAI", format = Documenter.HTML(), - modules = [RiskLabAI] + modules = [RiskLabAI, RiskLabAI.CausalFactorAnalysis], + source = ".", + pages = [ + "Home" => "src/index.md", + "Causal factor analysis" => "causal_factor_analysis.md", + "Compatibility" => "compatibility.md", + ], ) # Documenter can also automatically deploy documentation to gh-pages. diff --git a/docs/src/index.md b/docs/src/index.md index ee9d528..0c50560 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -1,6 +1,14 @@ # RiskLabAI.jl + +RiskLabAI provides quantitative-finance, financial-machine-learning, and +causal-factor-analysis tools. The 1.1.0 candidate preserves the released +57-name causal contract and adds 30 paper-derived names. + +See the causal-factor guide for the full 87-name analytical surface, behavioral +boundaries, source-blocked methods, and Python-Julia parity notes. See the +compatibility guide for supported Julia and dependency lanes. + ```@autodocs Modules = [RiskLabAI] Private = false ``` -Documentation for RiskLabAI.jl diff --git a/examples/causal_factor_analysis_quickstart.jl b/examples/causal_factor_analysis_quickstart.jl index 6736b7a..0fc7845 100644 --- a/examples/causal_factor_analysis_quickstart.jl +++ b/examples/causal_factor_analysis_quickstart.jl @@ -4,7 +4,7 @@ using RiskLabAI using RiskLabAI.CausalFactorAnalysis using LinearAlgebra: Diagonal -@assert Base.pkgversion(RiskLabAI) == v"1.0.0" +@assert Base.pkgversion(RiskLabAI) == v"1.1.0" covariance = Matrix(Diagonal([1.0, 2.0, 4.0])) factor_exposures = [1.0 0.0; 0.0 1.0; 1.0 1.0] @@ -26,7 +26,29 @@ dag = CausalDAG( ) @assert !check_backdoor_adjustment_set(dag, "T", "Y").admissible @assert check_backdoor_adjustment_set(dag, "T", "Y", ("U",)).admissible +role = classify_treatment_outcome_role(dag, "T", "Y", "U") +@assert role.role == TreatmentOutcomeRole(:confounder) + +generalized_coefficient = generalized_confounder_undercontrolled_coefficient( + 2.0, + 3.0, + 4.0; + confounder_variance = 5.0, + exposure_noise_variance = 6.0, +) +@assert generalized_coefficient == 116.0 / 43.0 + +family_errors = max_selection_family_errors( + 0.024997895148220373, + 0.9515427737332771, + 0.95, + 10, +) +@assert isapprox(family_errors.family_type_i_error, 0.22365361940347483) println("minimum-variance weights: ", weights) println("average treatment effect: ", effect) println("back-door adjustment set: (\"U\",)") +println("one-hop role for U: ", role.role.value) +println("general-variance confounder coefficient: ", generalized_coefficient) +println("ten-trial family Type-I error: ", family_errors.family_type_i_error) diff --git a/src/Backtest/BacktestStatistics.jl b/src/Backtest/BacktestStatistics.jl index 787617c..e38d0d1 100644 --- a/src/Backtest/BacktestStatistics.jl +++ b/src/Backtest/BacktestStatistics.jl @@ -187,7 +187,7 @@ end # max-drawdown. The LW test studentizes the Sharpe difference by a Bartlett HAC # standard error and calibrates it with a circular block bootstrap, holding # nominal size under serial dependence where the naive z-test over-rejects. -# Admitted in Appraisal 22 (`library_extension/appraisals/22_verdict.md`). +# Included after independent validation (the documented validation evidence). # CED + the naive test (and the LW delta/se/stat) are parity-matched exactly; the # LW bootstrap p-value uses Julia's RNG (behavioural). # --------------------------------------------------------------------------- # @@ -242,7 +242,7 @@ A tail MEAN of the max-drawdown distribution, so lower estimator variance and better drawdown-risk ranking than max-drawdown, converging to it on benign returns; coherent and factor-attributable. -Preferred-when / avoid-when (regime tag, verbatim from `CONTRIBUTIONS_LEDGER.md`): +Preferred-when / avoid-when guidance: prefer CED over max-drawdown as a drawdown-risk statistic (lower estimator variance, better ranking of true drawdown risk, most on short/heavy-tailed tracks; converges on benign returns). @@ -321,7 +321,7 @@ a Bartlett HAC standard error and calibrates the two-sided p-value with a studentized circular block bootstrap. Returns a `NamedTuple` `(delta, se, stat, pvalue, reject)`. -Preferred-when / avoid-when (regime tag, verbatim from `CONTRIBUTIONS_LEDGER.md`): +Preferred-when / avoid-when guidance: prefer the Ledoit-Wolf bootstrap Sharpe-difference test when comparing two Sharpes under serial dependence/heavy tails (holds nominal size where the naive test inflates ~3×; converges on i.i.d.). diff --git a/src/Backtest/MultipleTesting.jl b/src/Backtest/MultipleTesting.jl index 5f04e2f..0f104f4 100644 --- a/src/Backtest/MultipleTesting.jl +++ b/src/Backtest/MultipleTesting.jl @@ -12,8 +12,8 @@ family with a stated error-control target. Clean-room Julia port of the validated Python `RiskLabAI.backtest.multiple_testing` reference (numeric parity asserted in -`test/runtests.jl`). Admitted in Appraisal 07 -(`library_extension/appraisals/07_verdict.md`). +`test/runtests.jl`). Included after independent validation +(the documented validation evidence). References: Holm, S. (1979), Scandinavian Journal of Statistics 6(2); Benjamini, Y. & Yekutieli, D. (2001), Annals of Statistics 29(4); Harvey, C. R. & @@ -94,7 +94,7 @@ haircut Sharpe (the Sharpe implied by the adjusted p-value, `Φ⁻¹(1 - p_adj)/ Returns a `NamedTuple` `(p_values, adjusted_p_values, significant, haircut_sharpe_ratios)`. -Preferred-when / avoid-when (regime tag, verbatim from `CONTRIBUTIONS_LEDGER.md`): +Preferred-when / avoid-when guidance: to judge a *family* of screened strategies/factors (not just the single best), prefer Holm when you must control the chance of any false positive across the family (FWER), and BHY when you want to bound the expected fraction of false diff --git a/src/Backtest/OUTradingRules.jl b/src/Backtest/OUTradingRules.jl index 5ab957a..562ea10 100644 --- a/src/Backtest/OUTradingRules.jl +++ b/src/Backtest/OUTradingRules.jl @@ -15,8 +15,8 @@ is the expected net return per unit time `E[gain] / E[τ]`. Clean-room Julia port; the closed-form metrics are deterministic and parity-matched in `test/runtests.jl`. Deliberate divergence: the optimizer replaces SciPy's L-BFGS-B with a coarse-to-fine grid maximization of the same closed-form objective -(no Optim dependency). Admitted in Appraisal 23 -(`library_extension/appraisals/23_verdict.md`). +(no Optim dependency). Included after independent validation +(the documented validation evidence). References: Lipton, A. & López de Prado, M. (2020), A closed-form solution for optimal mean-reverting trading strategies; López de Prado (2018), AFML ch.13. @@ -165,7 +165,7 @@ Exact optimal OU profit-take / stop-loss by maximizing the closed-form expected return per unit time. Returns a `NamedTuple` `(profit_take, stop_loss, ...)` merged with the full `ou_rule_metrics` of the optimum. -Preferred-when / avoid-when (regime tag, verbatim from `CONTRIBUTIONS_LEDGER.md`): +Preferred-when / avoid-when guidance: prefer the closed-form OU rule over the Monte-Carlo grid whenever the OU model is used — it reproduces the grid optimum within resolution, 18–350× faster, with no simulation noise; it degrades in step with the grid off-model (no robustness to diff --git a/src/Backtest/ProbabilisticSharpeRatio.jl b/src/Backtest/ProbabilisticSharpeRatio.jl index b998f1a..ecd3cb6 100644 --- a/src/Backtest/ProbabilisticSharpeRatio.jl +++ b/src/Backtest/ProbabilisticSharpeRatio.jl @@ -73,7 +73,7 @@ end # the Sharpe influence function, correcting for both at once; it converges to the # PSR denominator (1 − S·SR̂ + (K−1)/4·SR̂²) under i.i.d. returns. Clean-room from # the influence-function / HAC math; numeric parity asserted in `test/runtests.jl`. -# Admitted in Appraisal 08 (`library_extension/appraisals/08_verdict.md`). +# Included after independent validation (the documented validation evidence). # --------------------------------------------------------------------------- # using Distributions: ccdf @@ -138,7 +138,7 @@ confidence_interval, test_statistic, p_value, significant, lag)`. The standard error is `√(Ω̂/T)` with `Ω̂` the Newey–West long-run variance of the Sharpe influence function. -Preferred-when / avoid-when (regime tag, verbatim from `CONTRIBUTIONS_LEDGER.md`): +Preferred-when / avoid-when guidance: prefer LPLZ for Sharpe-ratio inference when returns show material serial correlation and/or non-normality (estimable from the sample): it restores near-nominal CI coverage and test size where the PSR under-covers and over-rejects diff --git a/src/CausalFactorAnalysis/AllocationDiagnostics.jl b/src/CausalFactorAnalysis/AllocationDiagnostics.jl new file mode 100644 index 0000000..5eb91b5 --- /dev/null +++ b/src/CausalFactorAnalysis/AllocationDiagnostics.jl @@ -0,0 +1,256 @@ +function _allocation_real_matrix(name::AbstractString, values) + values isa AbstractMatrix || throw(ArgumentError("$name must be a numeric matrix")) + all(value -> value isa Real && !(value isa Bool), values) || + throw(ArgumentError("$name must be real-valued and must not contain booleans")) + result = try + Matrix{Float64}(values) + catch + throw(ArgumentError("$name must be a numeric matrix")) + end + all(isfinite, result) || throw(ArgumentError("$name must contain only finite values")) + return result +end + +function _allocation_real_vector(name::AbstractString, values) + values isa AbstractVector || throw(ArgumentError("$name must be a numeric vector")) + all(value -> value isa Real && !(value isa Bool), values) || + throw(ArgumentError("$name must be real-valued and must not contain booleans")) + result = try + Vector{Float64}(values) + catch + throw(ArgumentError("$name must be a numeric vector")) + end + all(isfinite, result) || throw(ArgumentError("$name must contain only finite values")) + return result +end + +function _allocation_float_snapshot(name::AbstractString, values) + return _allocation_real_vector(name, values) +end + +function _allocation_bool_snapshot(name::AbstractString, values) + values isa AbstractVector || throw(ArgumentError("$name must be a boolean vector")) + all(value -> value isa Bool, values) || + throw(ArgumentError("$name must be a boolean vector")) + return Vector{Bool}(values) +end + +function _allocation_nonnegative_scalar(name::AbstractString, value) + value isa Real && !(value isa Bool) || + throw(ArgumentError("$name must be a nonnegative finite scalar")) + result = try + Float64(value) + catch + throw(ArgumentError("$name must be a nonnegative finite scalar")) + end + isfinite(result) && result >= 0.0 || + throw(ArgumentError("$name must be a nonnegative finite scalar")) + return result +end + +""" + AllocationMisspecificationDiagnostics + +Evidence comparing minimum-variance allocations built from reference and +misspecified exposure systems. Every vector field is an independent concrete +snapshot. `reference_exposure_error` is the reference-system exposure of the +misspecified weights minus `reference_target_exposures`. +""" +struct AllocationMisspecificationDiagnostics + reference_weights::Vector{Float64} + misspecified_weights::Vector{Float64} + reference_target_exposures::Vector{Float64} + reference_achieved_exposures::Vector{Float64} + misspecified_reference_exposures::Vector{Float64} + misspecified_model_target_exposures::Vector{Float64} + misspecified_model_achieved_exposures::Vector{Float64} + reference_exposure_error::Vector{Float64} + weight_sign_reversals::Vector{Bool} + reference_variance::Float64 + misspecified_variance::Float64 + + function AllocationMisspecificationDiagnostics( + reference_weights, + misspecified_weights, + reference_target_exposures, + reference_achieved_exposures, + misspecified_reference_exposures, + misspecified_model_target_exposures, + misspecified_model_achieved_exposures, + reference_exposure_error, + weight_sign_reversals, + reference_variance, + misspecified_variance, + ) + return new( + _allocation_float_snapshot("reference_weights", reference_weights), + _allocation_float_snapshot("misspecified_weights", misspecified_weights), + _allocation_float_snapshot( + "reference_target_exposures", + reference_target_exposures, + ), + _allocation_float_snapshot( + "reference_achieved_exposures", + reference_achieved_exposures, + ), + _allocation_float_snapshot( + "misspecified_reference_exposures", + misspecified_reference_exposures, + ), + _allocation_float_snapshot( + "misspecified_model_target_exposures", + misspecified_model_target_exposures, + ), + _allocation_float_snapshot( + "misspecified_model_achieved_exposures", + misspecified_model_achieved_exposures, + ), + _allocation_float_snapshot( + "reference_exposure_error", + reference_exposure_error, + ), + _allocation_bool_snapshot("weight_sign_reversals", weight_sign_reversals), + _allocation_nonnegative_scalar("reference_variance", reference_variance), + _allocation_nonnegative_scalar("misspecified_variance", misspecified_variance), + ) + end +end + +function _validate_allocation_shapes( + covariance::Matrix{Float64}, + reference_factor_exposures::Matrix{Float64}, + reference_target_exposures::Vector{Float64}, + misspecified_factor_exposures::Matrix{Float64}, + misspecified_target_exposures::Vector{Float64}, +) + n_assets, covariance_columns = size(covariance) + n_assets > 0 && covariance_columns == n_assets || + throw(ArgumentError("covariance must be a nonempty square matrix")) + + for (name, exposures, targets) in ( + ("reference", reference_factor_exposures, reference_target_exposures), + ("misspecified", misspecified_factor_exposures, misspecified_target_exposures), + ) + size(exposures, 1) == n_assets || throw( + ArgumentError( + "$(name)_factor_exposures must have the same number of rows as covariance", + ), + ) + n_factors = size(exposures, 2) + n_factors > 0 || throw( + ArgumentError( + "$(name)_factor_exposures must contain at least one factor column", + ), + ) + n_factors <= n_assets || throw( + ArgumentError("$(name)_factor_exposures cannot have more columns than rows"), + ) + length(targets) == n_factors || throw( + ArgumentError( + "$(name)_target_exposures length must equal the number of $(name) factor columns", + ), + ) + end + return nothing +end + +function _allocation_portfolio_variance( + covariance::Matrix{Float64}, + weights::Vector{Float64}, + name::AbstractString, +) + result = dot(weights, covariance, weights) + isfinite(result) && result >= 0.0 || + throw(ArgumentError("$name cannot be represented as a nonnegative finite Float64")) + return result +end + +""" + allocation_misspecification_diagnostics( + covariance, + reference_factor_exposures, + reference_target_exposures, + misspecified_factor_exposures, + misspecified_target_exposures, + ) + +Solve the two equality-constrained minimum-variance allocations and evaluate +both against the reference covariance and exposure system. The two exposure +matrices must have the same asset rows but may have different factor counts. +A sign reversal is recorded only when both paired weights are nonzero and have +opposite signs. +""" +function allocation_misspecification_diagnostics( + covariance, + reference_factor_exposures, + reference_target_exposures, + misspecified_factor_exposures, + misspecified_target_exposures, +) + covariance_array = _allocation_real_matrix("covariance", covariance) + reference_exposure_array = + _allocation_real_matrix("reference_factor_exposures", reference_factor_exposures) + reference_target_array = + _allocation_real_vector("reference_target_exposures", reference_target_exposures) + misspecified_exposure_array = _allocation_real_matrix( + "misspecified_factor_exposures", + misspecified_factor_exposures, + ) + misspecified_target_array = _allocation_real_vector( + "misspecified_target_exposures", + misspecified_target_exposures, + ) + _validate_allocation_shapes( + covariance_array, + reference_exposure_array, + reference_target_array, + misspecified_exposure_array, + misspecified_target_array, + ) + + reference_weights = minimum_variance_factor_weights( + covariance_array, + reference_exposure_array, + reference_target_array, + ) + misspecified_weights = minimum_variance_factor_weights( + covariance_array, + misspecified_exposure_array, + misspecified_target_array, + ) + + reference_achieved_exposures = transpose(reference_exposure_array) * reference_weights + misspecified_reference_exposures = + transpose(reference_exposure_array) * misspecified_weights + misspecified_model_achieved_exposures = + transpose(misspecified_exposure_array) * misspecified_weights + reference_exposure_error = misspecified_reference_exposures - reference_target_array + weight_sign_reversals = + map(reference_weights, misspecified_weights) do reference, misspecified + !iszero(reference) && + !iszero(misspecified) && + signbit(reference) != signbit(misspecified) + end + + return AllocationMisspecificationDiagnostics( + reference_weights, + misspecified_weights, + reference_target_array, + reference_achieved_exposures, + misspecified_reference_exposures, + misspecified_target_array, + misspecified_model_achieved_exposures, + reference_exposure_error, + weight_sign_reversals, + _allocation_portfolio_variance( + covariance_array, + reference_weights, + "reference_variance", + ), + _allocation_portfolio_variance( + covariance_array, + misspecified_weights, + "misspecified_variance", + ), + ) +end diff --git a/src/CausalFactorAnalysis/CausalFactorAnalysis.jl b/src/CausalFactorAnalysis/CausalFactorAnalysis.jl index e0d054f..405e46f 100644 --- a/src/CausalFactorAnalysis/CausalFactorAnalysis.jl +++ b/src/CausalFactorAnalysis/CausalFactorAnalysis.jl @@ -10,6 +10,10 @@ include("FactorMirage.jl") include("Optimizer.jl") include("GraphIdentification.jl") include("Protocol.jl") +include("AllocationDiagnostics.jl") +include("GraphRoles.jl") +include("StructuralModels.jl") +include("FalseDiscoveryRates.jl") export BackdoorAdjustmentEvidence, BacktestStage, @@ -67,6 +71,36 @@ export BackdoorAdjustmentEvidence, minimum_variance_factor_weights, randomized_mean_difference, treatment_effect_decomposition, - validate_causal_factor_protocol + validate_causal_factor_protocol, + AllocationMisspecificationDiagnostics, + FDRComparisonEvidence, + FDRNonIdentificationWitness, + FactorControlRoles, + GaussianSearchAdjustedFDR, + GaussianTrialMixture, + MaxSelectionFamilyErrors, + SelectionLevelProbabilityEvidence, + StructuralCausalModelResult, + TreatmentOutcomeRole, + TreatmentOutcomeRoleEvidence, + allocation_misspecification_diagnostics, + classify_treatment_outcome_role, + compare_single_and_family_fdr, + conditional_upper_tail_probability, + evaluate_structural_causal_model, + factor_control_roles, + family_level_false_discovery_rate, + fdr_nonidentification_witness, + gaussian_max_selection_cdf, + gaussian_max_selection_log_density, + gaussian_max_selection_log_likelihood, + gaussian_search_adjusted_false_discovery_rate, + gaussian_trial_mixture_cdf, + generalized_collider_overcontrolled_coefficients, + generalized_confounder_undercontrolled_coefficient, + max_selection_family_errors, + max_selection_null_probability, + maximum_mixture_cdf, + single_trial_false_discovery_rate end diff --git a/src/CausalFactorAnalysis/FactorMirage.jl b/src/CausalFactorAnalysis/FactorMirage.jl index 2210c70..03f4d9a 100644 --- a/src/CausalFactorAnalysis/FactorMirage.jl +++ b/src/CausalFactorAnalysis/FactorMirage.jl @@ -217,3 +217,83 @@ function collider_model_diagnostics(beta, gamma, delta, n_observations) overcontrolled_numerator^2 > beta_value^2 * diagnostic_sum, ) end + +function _positive_exact_fraction(name::AbstractString, value) + result = _exact_fraction(name, value) + result > 0 || throw(ArgumentError("$name must be strictly positive")) + return result +end + +""" + generalized_confounder_undercontrolled_coefficient( + beta, gamma, delta; + confounder_variance, + exposure_noise_variance, + ) + +Return the population exposure coefficient when a confounder is omitted and +the confounder and exposure-disturbance variances are not standardized. Both +variances must be strictly positive. Unit variances reproduce +[`confounder_undercontrolled_coefficient`](@ref). +""" +function generalized_confounder_undercontrolled_coefficient( + beta, + gamma, + delta; + confounder_variance, + exposure_noise_variance, +) + beta_value = _exact_fraction("beta", beta) + gamma_value = _exact_fraction("gamma", gamma) + delta_value = _exact_fraction("delta", delta) + confounder_variance_value = + _positive_exact_fraction("confounder_variance", confounder_variance) + exposure_noise_variance_value = + _positive_exact_fraction("exposure_noise_variance", exposure_noise_variance) + + denominator = delta_value^2 * confounder_variance_value + exposure_noise_variance_value + coefficient = + beta_value + delta_value * gamma_value * confounder_variance_value / denominator + return _finite_output("generalized undercontrolled coefficient", coefficient) +end + +""" + generalized_collider_overcontrolled_coefficients( + beta, gamma, delta; + outcome_noise_variance, + collider_noise_variance, + ) + +Return the two population coefficients after conditioning on a collider when +the outcome and collider disturbance variances are not standardized. Both +variances must be strictly positive. Unit variances reproduce +[`collider_overcontrolled_coefficients`](@ref). +""" +function generalized_collider_overcontrolled_coefficients( + beta, + gamma, + delta; + outcome_noise_variance, + collider_noise_variance, +) + beta_value = _exact_fraction("beta", beta) + gamma_value = _exact_fraction("gamma", gamma) + delta_value = _exact_fraction("delta", delta) + outcome_noise_variance_value = + _positive_exact_fraction("outcome_noise_variance", outcome_noise_variance) + collider_noise_variance_value = + _positive_exact_fraction("collider_noise_variance", collider_noise_variance) + + denominator = + collider_noise_variance_value + gamma_value^2 * outcome_noise_variance_value + beta_hat = + ( + beta_value * collider_noise_variance_value - + delta_value * gamma_value * outcome_noise_variance_value + ) / denominator + theta_hat = gamma_value * outcome_noise_variance_value / denominator + return ColliderCoefficients( + _finite_output("generalized overcontrolled beta coefficient", beta_hat), + _finite_output("generalized collider coefficient", theta_hat), + ) +end diff --git a/src/CausalFactorAnalysis/FalseDiscoveryRates.jl b/src/CausalFactorAnalysis/FalseDiscoveryRates.jl new file mode 100644 index 0000000..f5e6b42 --- /dev/null +++ b/src/CausalFactorAnalysis/FalseDiscoveryRates.jl @@ -0,0 +1,584 @@ +using Distributions: Normal, ccdf, cdf, logcdf, logpdf +using QuadGK: quadgk + +"Family error probabilities induced by selecting the maximum of K trials." +struct MaxSelectionFamilyErrors + n_trials::Int + trial_null_probability::Float64 + trial_type_i_error::Float64 + trial_type_ii_error::Float64 + family_null_probability::Float64 + family_type_i_error::Float64 + family_type_ii_error::Float64 +end + +"Log-domain evidence for the source's single-versus-family FDR conditions." +struct FDRComparisonEvidence + n_trials::Int + single_trial_false_discovery_rate::Float64 + family_level_false_discovery_rate::Float64 + log_observed_type_i_inflation::Float64 + log_required_type_i_inflation::Float64 + equation_13_log_gap::Float64 + identification_condition_holds::Bool + single_trial_upper_bounds_family::Bool +end + +"Two-component Gaussian model for one candidate specification." +struct GaussianTrialMixture + trial_null_probability::Float64 + null_standard_deviation::Float64 + alternative_mean::Float64 + alternative_standard_deviation::Float64 + + function GaussianTrialMixture( + trial_null_probability, + null_standard_deviation, + alternative_mean, + alternative_standard_deviation, + ) + probability = _fdr_probability("trial_null_probability", trial_null_probability) + null_scale = _fdr_positive("null_standard_deviation", null_standard_deviation) + alternative_location = _fdr_finite("alternative_mean", alternative_mean) + alternative_scale = + _fdr_positive("alternative_standard_deviation", alternative_standard_deviation) + return new(probability, null_scale, alternative_location, alternative_scale) + end +end + +"Per-threshold and aggregate Gaussian search-adjustment evidence." +struct GaussianSearchAdjustedFDR + n_trials::Int + thresholds::Vector{Float64} + trial_type_i_errors::Vector{Float64} + trial_type_ii_errors::Vector{Float64} + family_type_i_errors::Vector{Float64} + family_type_ii_errors::Vector{Float64} + mean_family_type_i_error::Float64 + mean_family_type_ii_error::Float64 + family_null_probability::Float64 + false_discovery_rate::Float64 + + function GaussianSearchAdjustedFDR( + n_trials, + thresholds, + trial_type_i_errors, + trial_type_ii_errors, + family_type_i_errors, + family_type_ii_errors, + mean_family_type_i_error, + mean_family_type_ii_error, + family_null_probability, + false_discovery_rate, + ) + trials = _fdr_positive_integer("n_trials", n_trials) + vectors = map( + pair -> _fdr_vector(pair[1], pair[2]), + ( + ("thresholds", thresholds), + ("trial_type_i_errors", trial_type_i_errors), + ("trial_type_ii_errors", trial_type_ii_errors), + ("family_type_i_errors", family_type_i_errors), + ("family_type_ii_errors", family_type_ii_errors), + ), + ) + isempty(first(vectors)) && + throw(ArgumentError("threshold evidence vectors must be nonempty")) + all(length(vector) == length(first(vectors)) for vector in vectors) || + throw(ArgumentError("threshold evidence vectors must have equal lengths")) + return new( + trials, + vectors..., + _fdr_probability("mean_family_type_i_error", mean_family_type_i_error), + _fdr_probability("mean_family_type_ii_error", mean_family_type_ii_error), + _fdr_probability("family_null_probability", family_null_probability), + _fdr_probability("false_discovery_rate", false_discovery_rate), + ) + end +end + +"Two-trial equal-component construction proving unrestricted nonidentification." +struct FDRNonIdentificationWitness + n_trials::Int + target_false_discovery_rate::Float64 + trial_null_probability::Float64 + observable_cdf_at_threshold::Float64 + latent_trial_cdf_at_threshold::Float64 + family_null_probability::Float64 + family_type_i_error::Float64 + family_type_ii_error::Float64 + family_false_discovery_rate::Float64 + reconstructed_observable_cdf_at_threshold::Float64 +end + +"Probability evidence that a selected maximum is a null candidate." +struct SelectionLevelProbabilityEvidence + joint_null_and_selection_probability::Float64 + selection_probability::Float64 + conditional_null_probability::Float64 + quadrature_absolute_error::Float64 +end + +function _fdr_finite(name::AbstractString, value) + value isa Bool && throw(ArgumentError("$name must be a real scalar")) + value isa Real || throw(ArgumentError("$name must be a real scalar")) + result = try + Float64(value) + catch + throw(ArgumentError("$name must be a representable real scalar")) + end + isfinite(result) || throw(ArgumentError("$name must be finite")) + return result +end + +function _fdr_probability(name::AbstractString, value; strict::Bool = false) + result = _fdr_finite(name, value) + valid = strict ? 0.0 < result < 1.0 : 0.0 <= result <= 1.0 + valid || throw( + ArgumentError( + strict ? "$name must be strictly between zero and one" : + "$name must be between zero and one", + ), + ) + return result +end + +function _fdr_positive(name::AbstractString, value) + result = _fdr_finite(name, value) + result > 0.0 || throw(ArgumentError("$name must be positive")) + return result +end + +function _fdr_nonnegative(name::AbstractString, value) + result = _fdr_finite(name, value) + result >= 0.0 || throw(ArgumentError("$name must be nonnegative")) + return result +end + +function _fdr_positive_integer(name::AbstractString, value) + value isa Bool && throw(ArgumentError("$name must be an integer")) + value isa Integer || throw(ArgumentError("$name must be an integer")) + value > 0 || throw(ArgumentError("$name must be positive")) + value <= typemax(Int) || throw(ArgumentError("$name is too large")) + return Int(value) +end + +function _fdr_vector(name::AbstractString, values) + values isa AbstractVector || + throw(ArgumentError("$name must be a one-dimensional real vector")) + result = Vector{Float64}(undef, length(values)) + for (index, value) in enumerate(values) + result[index] = _fdr_finite("$name[$index]", value) + end + return result +end + +_fdr_log_probability(value::Float64) = iszero(value) ? -Inf : log(value) +_fdr_log_complement(value::Float64) = isone(value) ? -Inf : log1p(-value) + +function _fdr_log_one_minus_exp(log_value::Float64) + log_value <= 0.0 || throw(ArgumentError("log probability must not be positive")) + log_value == -Inf && return 0.0 + iszero(log_value) && return -Inf + return log_value < -log(2.0) ? log1p(-exp(log_value)) : log(-expm1(log_value)) +end + +function _fdr_probability_power(probability::Float64, n_trials::Int) + iszero(probability) && return 0.0 + isone(probability) && return 1.0 + return exp(n_trials * log(probability)) +end + +function _fdr_one_minus_probability_power(probability::Float64, n_trials::Int) + iszero(probability) && return 1.0 + isone(probability) && return 0.0 + return -expm1(n_trials * log(probability)) +end + +function _fdr_logsumexp(first::Float64, second::Float64) + maximum_value = max(first, second) + maximum_value == -Inf && return -Inf + return maximum_value + log(exp(first - maximum_value) + exp(second - maximum_value)) +end + +function _fdr_posterior(log_null::Float64, log_alternative::Float64) + log_null == -Inf && + log_alternative == -Inf && + throw(ArgumentError("the discovery probability must be positive")) + log_null == -Inf && return 0.0 + log_alternative == -Inf && return 1.0 + return exp(log_null - _fdr_logsumexp(log_null, log_alternative)) +end + +function _fdr_log_weighted_pair(weight::Float64, first::Float64, second::Float64) + iszero(weight) && return second + isone(weight) && return first + return _fdr_logsumexp(log(weight) + first, log1p(-weight) + second) +end + +""" + single_trial_false_discovery_rate(type_i_error, type_ii_error, trial_null_probability) + +Return the single-trial posterior false-discovery rate from Equation 6. +""" +function single_trial_false_discovery_rate( + type_i_error, + type_ii_error, + trial_null_probability, +) + alpha = _fdr_probability("type_i_error", type_i_error) + beta = _fdr_probability("type_ii_error", type_ii_error) + pi_zero = _fdr_probability("trial_null_probability", trial_null_probability) + return _fdr_posterior( + _fdr_log_probability(alpha) + _fdr_log_probability(pi_zero), + _fdr_log_complement(beta) + _fdr_log_complement(pi_zero), + ) +end + +""" + family_level_false_discovery_rate(family_type_i_error, family_type_ii_error, + trial_null_probability, n_trials) + +Return the family-level search-adjusted false-discovery rate from Equations 9-10. +""" +function family_level_false_discovery_rate( + family_type_i_error, + family_type_ii_error, + trial_null_probability, + n_trials, +) + alpha_family = _fdr_probability("family_type_i_error", family_type_i_error) + beta_family = _fdr_probability("family_type_ii_error", family_type_ii_error) + pi_zero = _fdr_probability("trial_null_probability", trial_null_probability) + trials = _fdr_positive_integer("n_trials", n_trials) + log_family_null = trials * _fdr_log_probability(pi_zero) + return _fdr_posterior( + _fdr_log_probability(alpha_family) + log_family_null, + _fdr_log_complement(beta_family) + _fdr_log_one_minus_exp(log_family_null), + ) +end + +"Return maximum-selection family errors from Equations 17-18." +function max_selection_family_errors( + trial_type_i_error, + trial_type_ii_error, + trial_null_probability, + n_trials, +) + alpha = _fdr_probability("trial_type_i_error", trial_type_i_error) + beta = _fdr_probability("trial_type_ii_error", trial_type_ii_error) + pi_zero = + _fdr_probability("trial_null_probability", trial_null_probability; strict = true) + trials = _fdr_positive_integer("n_trials", n_trials) + + family_null = _fdr_probability_power(pi_zero, trials) + family_alpha = _fdr_one_minus_probability_power(1.0 - alpha, trials) + first_base = pi_zero * (1.0 - alpha) + (1.0 - pi_zero) * beta + second_base = pi_zero * (1.0 - alpha) + log_denominator = _fdr_log_one_minus_exp(trials * log(pi_zero)) + family_beta = if iszero(first_base) || first_base == second_base + 0.0 + else + log_first_power = trials * log(first_base) + log_numerator = if iszero(second_base) + log_first_power + else + log_ratio = trials * (log(second_base) - log(first_base)) + log_first_power + _fdr_log_one_minus_exp(log_ratio) + end + clamp(exp(log_numerator - log_denominator), 0.0, 1.0) + end + return MaxSelectionFamilyErrors( + trials, + pi_zero, + alpha, + beta, + family_null, + family_alpha, + family_beta, + ) +end + +"Evaluate the equality and upper-bound conditions in Equations 13-15." +function compare_single_and_family_fdr( + trial_type_i_error, + trial_type_ii_error, + trial_null_probability, + n_trials; + family_type_i_error, + family_type_ii_error, + relative_tolerance = 1e-12, + absolute_tolerance = 1e-15, +) + alpha = _fdr_probability("trial_type_i_error", trial_type_i_error; strict = true) + beta = _fdr_probability("trial_type_ii_error", trial_type_ii_error; strict = true) + pi_zero = + _fdr_probability("trial_null_probability", trial_null_probability; strict = true) + trials = _fdr_positive_integer("n_trials", n_trials) + alpha_family = + _fdr_probability("family_type_i_error", family_type_i_error; strict = true) + beta_family = + _fdr_probability("family_type_ii_error", family_type_ii_error; strict = true) + relative = _fdr_nonnegative("relative_tolerance", relative_tolerance) + absolute = _fdr_nonnegative("absolute_tolerance", absolute_tolerance) + + log_observed = log(alpha_family) - log(alpha) + log_family_null = trials * log(pi_zero) + log_required = + log1p(-beta_family) - log1p(-beta) + _fdr_log_one_minus_exp(log_family_null) - + (trials - 1) * log(pi_zero) - log1p(-pi_zero) + gap = log_observed - log_required + scale_tolerance = max(absolute, relative * max(abs(log_observed), abs(log_required))) + equality = isapprox(log_observed, log_required; rtol = relative, atol = absolute) + return FDRComparisonEvidence( + trials, + single_trial_false_discovery_rate(alpha, beta, pi_zero), + family_level_false_discovery_rate(alpha_family, beta_family, pi_zero, trials), + log_observed, + log_required, + gap, + equality, + gap <= scale_tolerance, + ) +end + +"Return the CDF of the maximum in Equation 16 from component CDF values." +function maximum_mixture_cdf(null_cdf, alternative_cdf, trial_null_probability, n_trials) + null_value = _fdr_probability("null_cdf", null_cdf) + alternative_value = _fdr_probability("alternative_cdf", alternative_cdf) + pi_zero = _fdr_probability("trial_null_probability", trial_null_probability) + trials = _fdr_positive_integer("n_trials", n_trials) + mixture = clamp(pi_zero * null_value + (1.0 - pi_zero) * alternative_value, 0.0, 1.0) + return _fdr_probability_power(mixture, trials) +end + +"Return `P(X >= x | X >= c)` from Equation 26 for `x >= c`." +function conditional_upper_tail_probability(cdf_at_value, cdf_at_threshold) + at_value = _fdr_probability("cdf_at_value", cdf_at_value) + at_threshold = _fdr_probability("cdf_at_threshold", cdf_at_threshold) + isone(at_threshold) && throw(ArgumentError("cdf_at_threshold must be less than one")) + at_value >= at_threshold || + throw(ArgumentError("cdf_at_value must not be below cdf_at_threshold")) + return (1.0 - at_value) / (1.0 - at_threshold) +end + +"Return the Gaussian trial-mixture CDF in Equations 33-35." +function gaussian_trial_mixture_cdf(model::GaussianTrialMixture, x) + value = _fdr_finite("x", x) + null_cdf = cdf(Normal(0.0, model.null_standard_deviation), value) + alternative_cdf = + cdf(Normal(model.alternative_mean, model.alternative_standard_deviation), value) + return clamp( + model.trial_null_probability * null_cdf + + (1.0 - model.trial_null_probability) * alternative_cdf, + 0.0, + 1.0, + ) +end + +"Return the selected Gaussian maximum CDF in Equation 35." +function gaussian_max_selection_cdf(model::GaussianTrialMixture, x, n_trials) + trials = _fdr_positive_integer("n_trials", n_trials) + return _fdr_probability_power(gaussian_trial_mixture_cdf(model, x), trials) +end + +function _fdr_gaussian_component_logs(model::GaussianTrialMixture, x::Float64) + null_distribution = Normal(0.0, model.null_standard_deviation) + alternative_distribution = + Normal(model.alternative_mean, model.alternative_standard_deviation) + return ( + logcdf(null_distribution, x), + logcdf(alternative_distribution, x), + logpdf(null_distribution, x), + logpdf(alternative_distribution, x), + ) +end + +"Return the log density of the selected Gaussian maximum from Equation 36." +function gaussian_max_selection_log_density(model::GaussianTrialMixture, x, n_trials) + value = _fdr_finite("x", x) + trials = _fdr_positive_integer("n_trials", n_trials) + (null_log_cdf, alternative_log_cdf, null_log_density, alternative_log_density) = + _fdr_gaussian_component_logs(model, value) + log_mixture_cdf = _fdr_log_weighted_pair( + model.trial_null_probability, + null_log_cdf, + alternative_log_cdf, + ) + log_mixture_density = _fdr_log_weighted_pair( + model.trial_null_probability, + null_log_density, + alternative_log_density, + ) + return log(trials) + (trials - 1) * log_mixture_cdf + log_mixture_density +end + +"Return the Equation 37 log-likelihood for selected maxima." +function gaussian_max_selection_log_likelihood( + model::GaussianTrialMixture, + observations, + n_trials, +) + values = _fdr_vector("observations", observations) + isempty(values) && throw(ArgumentError("observations must be nonempty")) + trials = _fdr_positive_integer("n_trials", n_trials) + result = + sum(gaussian_max_selection_log_density(model, value, trials) for value in values) + (isnan(result) || result == Inf) && + throw(ArgumentError("log-likelihood could not be evaluated")) + return result +end + +"Apply Equations 38-40 to a vector of Gaussian thresholds." +function gaussian_search_adjusted_false_discovery_rate( + model::GaussianTrialMixture, + thresholds, + n_trials, +) + threshold_values = _fdr_vector("thresholds", thresholds) + isempty(threshold_values) && throw(ArgumentError("thresholds must be nonempty")) + trials = _fdr_positive_integer("n_trials", n_trials) + null_distribution = Normal(0.0, model.null_standard_deviation) + alternative_distribution = + Normal(model.alternative_mean, model.alternative_standard_deviation) + trial_alpha = [ccdf(null_distribution, threshold) for threshold in threshold_values] + trial_beta = + [cdf(alternative_distribution, threshold) for threshold in threshold_values] + family_alpha = similar(trial_alpha) + family_beta = similar(trial_beta) + for index in eachindex(threshold_values) + evidence = max_selection_family_errors( + trial_alpha[index], + trial_beta[index], + model.trial_null_probability, + trials, + ) + family_alpha[index] = evidence.family_type_i_error + family_beta[index] = evidence.family_type_ii_error + end + mean_family_alpha = mean(family_alpha) + mean_family_beta = mean(family_beta) + family_null = _fdr_probability_power(model.trial_null_probability, trials) + false_discovery_rate = family_level_false_discovery_rate( + mean_family_alpha, + mean_family_beta, + model.trial_null_probability, + trials, + ) + return GaussianSearchAdjustedFDR( + trials, + threshold_values, + trial_alpha, + trial_beta, + family_alpha, + family_beta, + mean_family_alpha, + mean_family_beta, + family_null, + false_discovery_rate, + ) +end + +"Return the exact unrestricted two-trial witness from Equations 41-50." +function fdr_nonidentification_witness( + observable_cdf_at_threshold, + target_false_discovery_rate, +) + observable_cdf = _fdr_probability( + "observable_cdf_at_threshold", + observable_cdf_at_threshold; + strict = true, + ) + target = _fdr_probability( + "target_false_discovery_rate", + target_false_discovery_rate; + strict = true, + ) + trials = 2 + trial_null = sqrt(target) + latent_cdf = sqrt(observable_cdf) + family_null = trial_null^trials + family_alpha = 1.0 - observable_cdf + family_beta = observable_cdf + family_fdr = + family_level_false_discovery_rate(family_alpha, family_beta, trial_null, trials) + return FDRNonIdentificationWitness( + trials, + target, + trial_null, + observable_cdf, + latent_cdf, + family_null, + family_alpha, + family_beta, + family_fdr, + latent_cdf^trials, + ) +end + +""" + max_selection_null_probability(threshold, trial_null_probability, n_trials; + null_density, mixture_cdf, ...) + +Return the selected-winner null probability from footnote 25. The result is a +selection-level estimand and is deliberately distinct from family-level FDR. +""" +function max_selection_null_probability( + threshold, + trial_null_probability, + n_trials; + null_density, + mixture_cdf, + absolute_tolerance = 1e-12, + relative_tolerance = 1e-10, + max_subintervals = 200, +) + threshold_value = _fdr_finite("threshold", threshold) + pi_zero = _fdr_probability("trial_null_probability", trial_null_probability) + trials = _fdr_positive_integer("n_trials", n_trials) + absolute = _fdr_positive("absolute_tolerance", absolute_tolerance) + relative = _fdr_positive("relative_tolerance", relative_tolerance) + subdivisions = _fdr_positive_integer("max_subintervals", max_subintervals) + + checked_cdf(value) = _fdr_probability("mixture_cdf return value", mixture_cdf(value)) + mixture_at_threshold = checked_cdf(threshold_value) + selection_probability = _fdr_one_minus_probability_power(mixture_at_threshold, trials) + iszero(selection_probability) && + throw(ArgumentError("the selection probability must be positive")) + if iszero(pi_zero) + return SelectionLevelProbabilityEvidence(0.0, selection_probability, 0.0, 0.0) + end + + function integrand(value) + density = _fdr_finite("null_density return value", null_density(value)) + density >= 0.0 || + throw(ArgumentError("null_density return value must be nonnegative")) + return density * _fdr_probability_power(checked_cdf(value), trials - 1) + end + subdivisions <= typemax(Int) ÷ 15 || + throw(ArgumentError("max_subintervals is too large")) + integral, error = quadgk( + integrand, + threshold_value, + Inf; + atol = absolute, + rtol = relative, + maxevals = max(15, 15 * subdivisions), + ) + (isfinite(integral) && isfinite(error) && integral >= 0.0) || + throw(ArgumentError("selection-level quadrature returned invalid evidence")) + scale = trials * pi_zero + joint_probability = scale * integral + absolute_error = scale * error + comparison_tolerance = + max(absolute, relative * selection_probability, 8.0 * eps(Float64)) + joint_probability <= selection_probability + comparison_tolerance || + throw(ArgumentError("callbacks imply a null probability above one")) + joint_probability = clamp(joint_probability, 0.0, selection_probability) + return SelectionLevelProbabilityEvidence( + joint_probability, + selection_probability, + joint_probability / selection_probability, + absolute_error, + ) +end diff --git a/src/CausalFactorAnalysis/GraphRoles.jl b/src/CausalFactorAnalysis/GraphRoles.jl new file mode 100644 index 0000000..11776cc --- /dev/null +++ b/src/CausalFactorAnalysis/GraphRoles.jl @@ -0,0 +1,160 @@ +""" + FactorControlRoles + +Reachability roles for factors relative to one observed target in an accepted +causal DAG. The observed fields partition every observed non-target node. The +unobserved fields separately record latent reachability evidence. +""" +struct FactorControlRoles + target_factor::String + ancestor_factors::Tuple{Vararg{String}} + descendant_factors::Tuple{Vararg{String}} + other_factors::Tuple{Vararg{String}} + unobserved_ancestors::Tuple{Vararg{String}} + unobserved_descendants::Tuple{Vararg{String}} +end + +const _TREATMENT_OUTCOME_ROLE_VALUES = ( + :cause_of_treatment, + :consequence_of_treatment, + :cause_of_outcome, + :consequence_of_outcome, + :confounder, + :collider, + :mediator, + :independent, +) + +""" + TreatmentOutcomeRole(value) + +Validated symbol-backed representation of one of the eight admitted one-hop +treatment/outcome roles. +""" +struct TreatmentOutcomeRole + value::Symbol + + function TreatmentOutcomeRole(value::Symbol) + value in _TREATMENT_OUTCOME_ROLE_VALUES || + throw(ArgumentError("value must identify one of the eight admitted roles")) + return new(value) + end +end + +TreatmentOutcomeRole(value::AbstractString) = TreatmentOutcomeRole(Symbol(value)) + +Base.:(==)(left::TreatmentOutcomeRole, right::TreatmentOutcomeRole) = + left.value == right.value +Base.isequal(left::TreatmentOutcomeRole, right::TreatmentOutcomeRole) = + isequal(left.value, right.value) +Base.hash(role::TreatmentOutcomeRole, seed::UInt) = hash(role.value, seed) +Base.string(role::TreatmentOutcomeRole) = String(role.value) + +function Base.show(io::IO, role::TreatmentOutcomeRole) + return print(io, "TreatmentOutcomeRole(:", role.value, ")") +end + +"""Direct-edge evidence for one admitted treatment/outcome role.""" +struct TreatmentOutcomeRoleEvidence + node::String + treatment::String + outcome::String + role::TreatmentOutcomeRole + node_to_treatment::Bool + treatment_to_node::Bool + node_to_outcome::Bool + outcome_to_node::Bool +end + +function _role_observed_node(dag::CausalDAG, value, subject::AbstractString) + node = _node(value, subject) + node in dag.nodes || throw(ArgumentError("$subject must be a known graph node")) + node in dag.observed_nodes || + throw(ArgumentError("$subject must be an observed graph node")) + return node +end + +""" + factor_control_roles(dag, target_factor) + +Partition observed non-target factors into ancestors, descendants, and all +remaining factors. Latent ancestors and descendants are reported separately. +This is reachability evidence, not a claim that every ancestor is a minimal or +sufficient adjustment set. +""" +function factor_control_roles(dag::CausalDAG, target_factor) + target = _role_observed_node(dag, target_factor, "target_factor") + ancestors = _ancestors(dag, target) + descendants = _descendants(dag, target) + observed_candidates = setdiff(Set(dag.observed_nodes), Set((target,))) + unobserved = setdiff(Set(dag.nodes), Set(dag.observed_nodes)) + + ancestor_factors = intersect(observed_candidates, ancestors) + descendant_factors = intersect(observed_candidates, descendants) + other_factors = + setdiff(observed_candidates, union(ancestor_factors, descendant_factors)) + + return FactorControlRoles( + target, + Tuple(sort(collect(ancestor_factors))), + Tuple(sort(collect(descendant_factors))), + Tuple(sort(collect(other_factors))), + Tuple(sort(collect(intersect(unobserved, ancestors)))), + Tuple(sort(collect(intersect(unobserved, descendants)))), + ) +end + +factor_control_roles(dag, target_factor) = throw(ArgumentError("dag must be a CausalDAG")) + +function _role_from_signature(signature::NTuple{4,Bool}) + signature == (true, false, false, false) && + return TreatmentOutcomeRole(:cause_of_treatment) + signature == (false, true, false, false) && + return TreatmentOutcomeRole(:consequence_of_treatment) + signature == (false, false, true, false) && + return TreatmentOutcomeRole(:cause_of_outcome) + signature == (false, false, false, true) && + return TreatmentOutcomeRole(:consequence_of_outcome) + signature == (true, false, true, false) && return TreatmentOutcomeRole(:confounder) + signature == (false, true, false, true) && return TreatmentOutcomeRole(:collider) + signature == (false, true, true, false) && return TreatmentOutcomeRole(:mediator) + signature == (false, false, false, false) && return TreatmentOutcomeRole(:independent) + throw( + ArgumentError( + "the direct treatment/outcome edge signature is not one of the eight admitted roles", + ), + ) +end + +""" + classify_treatment_outcome_role(dag, treatment, outcome, node) + +Classify an observed node from its four direct-edge indicators relative to an +observed treatment and outcome. Only the eight published signatures are +accepted; reverse mediation and every other signature fail closed. +""" +function classify_treatment_outcome_role(dag::CausalDAG, treatment, outcome, node) + treatment_node = _role_observed_node(dag, treatment, "treatment") + outcome_node = _role_observed_node(dag, outcome, "outcome") + role_node = _role_observed_node(dag, node, "node") + length(Set((treatment_node, outcome_node, role_node))) == 3 || + throw(ArgumentError("treatment, outcome, and node must be distinct")) + + edges = Set(dag.directed_edges) + signature = ( + (role_node, treatment_node) in edges, + (treatment_node, role_node) in edges, + (role_node, outcome_node) in edges, + (outcome_node, role_node) in edges, + ) + return TreatmentOutcomeRoleEvidence( + role_node, + treatment_node, + outcome_node, + _role_from_signature(signature), + signature..., + ) +end + +classify_treatment_outcome_role(dag, treatment, outcome, node) = + throw(ArgumentError("dag must be a CausalDAG")) diff --git a/src/CausalFactorAnalysis/StructuralModels.jl b/src/CausalFactorAnalysis/StructuralModels.jl new file mode 100644 index 0000000..63c96ec --- /dev/null +++ b/src/CausalFactorAnalysis/StructuralModels.jl @@ -0,0 +1,185 @@ +"""Canonical node order and sample-by-node structural values.""" +struct StructuralCausalModelResult + node_order::Tuple{Vararg{String}} + values::Matrix{Float64} + + function StructuralCausalModelResult( + node_order::Tuple{Vararg{String}}, + values::Matrix{Float64}, + ::Val{:validated}, + ) + return new(node_order, copy(values)) + end +end + +function StructuralCausalModelResult(node_order, values) + nodes = try + Tuple(_node(node, "node_order member") for node in node_order) + catch error + error isa ArgumentError && rethrow() + throw(ArgumentError("node_order must be an iterable collection of node names")) + end + isempty(nodes) && throw(ArgumentError("node_order must be nonempty")) + length(unique(nodes)) == length(nodes) || + throw(ArgumentError("node_order must not contain duplicate nodes")) + matrix = _scm_real_matrix(values, "values") + size(matrix, 1) > 0 || throw(ArgumentError("values must contain at least one sample")) + size(matrix, 2) == length(nodes) || + throw(ArgumentError("values columns must match node_order")) + return StructuralCausalModelResult(nodes, matrix, Val(:validated)) +end + +function _scm_real_vector(values, subject::AbstractString; expected_length = nothing) + values isa AbstractVector || throw(ArgumentError("$subject must be a numeric vector")) + all(value -> value isa Real && !(value isa Bool), values) || + throw(ArgumentError("$subject must be real-valued and non-boolean")) + vector = try + Vector{Float64}(values) + catch + throw(ArgumentError("$subject must be a numeric vector")) + end + expected_length !== nothing && + length(vector) != expected_length && + throw(ArgumentError("$subject must have length $expected_length")) + all(isfinite, vector) || + throw(ArgumentError("$subject must contain only finite values")) + return copy(vector) +end + +function _scm_real_matrix(values, subject::AbstractString) + values isa AbstractMatrix || throw(ArgumentError("$subject must be a numeric matrix")) + all(value -> value isa Real && !(value isa Bool), values) || + throw(ArgumentError("$subject must be real-valued and non-boolean")) + matrix = try + Matrix{Float64}(values) + catch + throw(ArgumentError("$subject must be a numeric matrix")) + end + all(isfinite, matrix) || + throw(ArgumentError("$subject must contain only finite values")) + return copy(matrix) +end + +function _scm_key(key, subject::AbstractString) + key isa Symbol && return String(key) + key isa AbstractString && return String(key) + throw(ArgumentError("$subject keys must be strings or symbols")) +end + +function _scm_mapping(mapping, subject::AbstractString, graph_nodes::Set{String}) + entries = if mapping isa NamedTuple + [(String(key), getproperty(mapping, key)) for key in keys(mapping)] + elseif mapping isa AbstractDict + [(_scm_key(key, subject), value) for (key, value) in pairs(mapping)] + else + throw(ArgumentError("$subject must be an AbstractDict or NamedTuple")) + end + + normalized = Dict{String,Any}() + for (key, value) in entries + haskey(normalized, key) && + throw(ArgumentError("$subject must not contain duplicate normalized keys")) + normalized[key] = value + end + Set(keys(normalized)) == graph_nodes || + throw(ArgumentError("$subject must contain exactly one entry per graph node")) + return normalized +end + +function _scm_parent_map(dag::CausalDAG) + parents = Dict(node => String[] for node in dag.nodes) + for (source, target) in dag.directed_edges + push!(parents[target], source) + end + foreach(sort!, values(parents)) + return parents +end + +function _scm_lexical_topological_order(dag::CausalDAG, parents) + children = Dict(node => String[] for node in dag.nodes) + indegree = Dict(node => length(parents[node]) for node in dag.nodes) + for (source, target) in dag.directed_edges + push!(children[source], target) + end + foreach(sort!, values(children)) + + ready = sort([node for node in dag.nodes if indegree[node] == 0]) + ordered = String[] + while !isempty(ready) + node = popfirst!(ready) + push!(ordered, node) + for child in children[node] + indegree[child] -= 1 + if indegree[child] == 0 + push!(ready, child) + sort!(ready) + end + end + end + length(ordered) == length(dag.nodes) || throw(ArgumentError("dag must be acyclic")) + return Tuple(ordered) +end + +""" + evaluate_structural_causal_model(dag, mechanisms, exogenous_inputs) + +Evaluate one deterministic caller-supplied mechanism per graph node. Nodes +follow lexical tie-broken topological order; a mechanism's parent-matrix +columns follow lexical parent order. Each mechanism receives independent +`n_samples × n_parents` and `n_samples` snapshots and must return one finite +real vector of length `n_samples`. +""" +function evaluate_structural_causal_model(dag, mechanisms, exogenous_inputs) + dag isa CausalDAG || throw(ArgumentError("dag must be a CausalDAG")) + graph_nodes = Set(dag.nodes) + mechanism_snapshot = _scm_mapping(mechanisms, "mechanisms", graph_nodes) + exogenous_mapping = _scm_mapping(exogenous_inputs, "exogenous_inputs", graph_nodes) + + exogenous_snapshot = Dict{String,Vector{Float64}}() + n_samples = nothing + for node in dag.nodes + vector = _scm_real_vector( + exogenous_mapping[node], + "exogenous input for node $(repr(node))", + ) + if n_samples === nothing + n_samples = length(vector) + n_samples > 0 || + throw(ArgumentError("exogenous input vectors must contain a sample")) + elseif length(vector) != n_samples + throw(ArgumentError("all exogenous input vectors must have equal length")) + end + exogenous_snapshot[node] = vector + end + n_samples === nothing && throw(ArgumentError("dag must contain at least one node")) + + probe_parents = Matrix{Float64}(undef, n_samples, 0) + for node in dag.nodes + applicable(mechanism_snapshot[node], probe_parents, exogenous_snapshot[node]) || + throw(ArgumentError("mechanism for node $(repr(node)) must be callable")) + end + + parents = _scm_parent_map(dag) + node_order = _scm_lexical_topological_order(dag, parents) + evaluated = Dict{String,Vector{Float64}}() + for node in node_order + parent_nodes = parents[node] + parent_matrix = Matrix{Float64}(undef, n_samples, length(parent_nodes)) + for (column, parent) in enumerate(parent_nodes) + parent_matrix[:, column] = evaluated[parent] + end + raw_result = + mechanism_snapshot[node](copy(parent_matrix), copy(exogenous_snapshot[node])) + evaluated[node] = _scm_real_vector( + raw_result, + "mechanism result for node $(repr(node))"; + expected_length = n_samples, + ) + end + + values_matrix = Matrix{Float64}(undef, n_samples, length(node_order)) + for (column, node) in enumerate(node_order) + values_matrix[:, column] = evaluated[node] + end + return StructuralCausalModelResult(node_order, values_matrix, Val(:validated)) +end diff --git a/src/Data/Denoise/Denoising.jl b/src/Data/Denoise/Denoising.jl index 2f9bf24..9d66fae 100644 --- a/src/Data/Denoise/Denoising.jl +++ b/src/Data/Denoise/Denoising.jl @@ -228,10 +228,10 @@ end # bulk) or is non-stationary, that assumption breaks and clipping degenerates # toward the raw sample covariance. NERCOME instead regularizes the eigenvalues # by sample-splitting: eigenvectors come from one split, the oracle eigenvalues -# from projecting the held-out split's covariance onto them, averaged over many +# from projecting the validation sample split's covariance onto them, averaged over many # random splits. Clean-room from Lam (2016); a data-driven estimator (it takes -# the return matrix, not a covariance). Admitted in Appraisal 24 -# (`library_extension/appraisals/24_verdict.md`). +# the return matrix, not a covariance). Included after independent validation +# (the documented validation evidence). # # Stochastic-step divergence (documented): the split permutations use Julia's # `randperm`/`rng` rather than NumPy's PCG64 stream, so the estimate is @@ -265,7 +265,7 @@ averaged over the splits (each term is PSD, so the average is PSD). Returns are standardized to unit sample variance, NERCOME-cleaned in correlation space, then scaled back by the sample column standard deviations. -Preferred-when / avoid-when (regime tag, verbatim from `appraisals/24_verdict.md`): +Preferred-when / avoid-when guidance: prefer NERCOME over MP clipping for covariance estimation when the eigenvalue spectrum has no clean gap or is non-stationary (better accuracy and conditioning, and lower OOS volatility via NCO / min-variance); it converges to clipping on diff --git a/src/Data/Differentiation/Differentiation.jl b/src/Data/Differentiation/Differentiation.jl index 65356be..33e3509 100644 --- a/src/Data/Differentiation/Differentiation.jl +++ b/src/Data/Differentiation/Differentiation.jl @@ -175,9 +175,9 @@ end # approximation). de Prado's min-d-via-ADF is finite-sample biased and # under-differences; AFD anchors the order on a bias-corrected Hurst-blend # estimate of the increments with a CV-chosen FFD truncation, raised to the ADF -# stationarity boundary only if needed. Admitted in Appraisal 13 -# (`library_extension/appraisals/13_verdict.md`; real-data predictive-lift -# confirmation a tracked obligation). +# stationarity boundary only if needed. Included after independent validation +# (the documented validation evidence; real-data predictive-lift +# confirmation a future validation work). # # Deliberate divergence: the wavelet-variance Hurst component is an OPTIONAL # enhancement (`pywt` in Python). The Julia port carries no wavelet dependency, so @@ -290,7 +290,7 @@ order along `delta_grid` only as far as the ADF stationarity boundary if needed. Returns a `NamedTuple` `(order, d_hat, adf_pvalue, memory_retained, threshold, series)`. -Preferred-when / avoid-when (regime tag, verbatim from `CONTRIBUTIONS_LEDGER.md`): +Preferred-when / avoid-when guidance: prefer AFD over fixed-width FFD when the differencing order itself must be right — strong long memory and finite samples, where min-d under-differences (order error 0.064 vs FFD 0.371); on weak memory the gap narrows. The implemented AFD is a diff --git a/src/Data/Distance/DistanceMetric.jl b/src/Data/Distance/DistanceMetric.jl index 1bd1e60..3db1779 100644 --- a/src/Data/Distance/DistanceMetric.jl +++ b/src/Data/Distance/DistanceMetric.jl @@ -215,8 +215,8 @@ end # kNN distances (Chebyshev norm), distance correlation is a tuning-free nonlinear # dependence index in [0, 1]. Clean-room from the published math; numeric parity # asserted in `test/runtests.jl` (distance correlation exactly; KSG via the -# jitter-invariant integer neighbour counts). Admitted in Appraisal 11 -# (`library_extension/appraisals/11_verdict.md`). +# jitter-invariant integer neighbour counts). Included after independent validation +# (the documented validation evidence). # --------------------------------------------------------------------------- # using SpecialFunctions: digamma @@ -241,7 +241,7 @@ than histogram MI on short / nonlinear / heavy-tailed samples; it can return a slightly negative value for (near-)independent data (a characterized property, not an error). -Preferred-when / avoid-when (regime tag, verbatim from `CONTRIBUTIONS_LEDGER.md`): +Preferred-when / avoid-when guidance: prefer KSG over binned MI/VI on short, noisy, or nonlinear/heavy-tailed samples (e.g. nonlinear-monotone n=1000 RMSE 0.039 vs binned 0.164); it is essentially unbiased on linear dependence and converges to binned on large-sample near-linear @@ -304,7 +304,7 @@ from the double-centred pairwise-distance matrices `A`, `B`: `dCor = √(mean(A·B) / √(mean(A·A)·mean(B·B)))`. Zero only at population independence; detects nonlinear dependence with no estimation parameter. -Preferred-when / avoid-when (regime tag, verbatim from `CONTRIBUTIONS_LEDGER.md`): +Preferred-when / avoid-when guidance: prefer distance correlation as a parameter-free nonlinear screen / for maximally stable clustering (best real-data ONC stability). It is a dependence index, not a metric on partitions like the variation of information (keep VI/KSG for the metric diff --git a/src/Features/EntropyFeatures.jl b/src/Features/EntropyFeatures.jl index 62f8ce4..b3261f1 100644 --- a/src/Features/EntropyFeatures.jl +++ b/src/Features/EntropyFeatures.jl @@ -151,7 +151,7 @@ end # missing mass back: Miller–Madow to first order, Grassberger to higher order, # NSB by integrating over a near-uniform-entropy Bayesian prior. All operate on # the same overlapping n-gram counts as the plug-in and return bits / word length. -# Admitted in Appraisal 06 (`library_extension/appraisals/06_verdict.md`). +# Included after independent validation (the documented validation evidence). # Clean-room from the published math; numeric parity asserted in `test/runtests.jl`. # --------------------------------------------------------------------------- # @@ -185,7 +185,7 @@ Miller–Madow bias-corrected Shannon entropy (bits per symbol): the plug-in entropy plus the first-order analytic correction `(K̂-1)/(2N)`, with K̂ the number of observed n-grams and N the count. The cheapest correction. -Preferred-when / avoid-when (regime tag, verbatim from `CONTRIBUTIONS_LEDGER.md`): +Preferred-when / avoid-when guidance: prefer a bias-corrected estimator over the plug-in whenever the symbol counts are undersampled (a large effective alphabet relative to the sample: long words, fine encodings, or short windows); Miller-Madow is the cheap first-order fix. It @@ -218,7 +218,7 @@ Grassberger (2008) bias-corrected Shannon entropy (bits per symbol): `H = ln N - (1/N) Σ nᵢ G(nᵢ)` converted to bits. A higher-order correction with no tuning, intermediate in cost between Miller–Madow and NSB. -Preferred-when / avoid-when (regime tag, verbatim from `CONTRIBUTIONS_LEDGER.md`): +Preferred-when / avoid-when guidance: prefer a bias-corrected estimator over the plug-in whenever the symbol counts are undersampled; Grassberger is close to NSB at lower cost (the practical default when undersampled). It converges to the plug-in when N is much larger than K, with @@ -322,7 +322,7 @@ effective alphabet of words is `alphabet_size ^ approximate_word_length`, defaul to the number of distinct symbols observed). The most accurate estimator in deep undersampling, and the most expensive. -Preferred-when / avoid-when (regime tag, verbatim from `CONTRIBUTIONS_LEDGER.md`): +Preferred-when / avoid-when guidance: prefer a bias-corrected estimator over the plug-in whenever the symbol counts are undersampled; NSB is most accurate in deep undersampling. It converges to the plug-in when N is much larger than K, with no over-correction; the gain is diff --git a/src/Features/FeatureImportance.jl b/src/Features/FeatureImportance.jl index 385e4d2..9d8c2aa 100644 --- a/src/Features/FeatureImportance.jl +++ b/src/Features/FeatureImportance.jl @@ -339,7 +339,7 @@ end # decision-stump basis and scores a feature by its out-of-bag partial variance; # CPI measures the model-loss increase when a feature is replaced by a # conditionally-resampled Gaussian knockoff, with a paired significance test. -# Admitted in Appraisal 10 (`library_extension/appraisals/10_verdict.md`). +# Included after independent validation (the documented validation evidence). # --------------------------------------------------------------------------- # using Random: randn @@ -397,7 +397,7 @@ feature by the out-of-bag partial variance of its block; the per-tree scores are averaged. The tree representation + regularised GLM + out-of-sample partial contribution remove the in-sample / cardinality inflation of plain MDI. -Preferred-when / avoid-when (regime tag, verbatim from `CONTRIBUTIONS_LEDGER.md`): +Preferred-when / avoid-when guidance: prefer MDI+ over MDI when features are noisy, high-cardinality, or mixed-type — it rejects noise/cardinality inflation (noise-rejection 0.94 vs MDI 0.50) and recovers true relevance better; it converges to MDI when features are orthogonal and @@ -457,7 +457,7 @@ Pooled across folds, the mean increase is the importance and a one-sided paired t-test gives a p-value, so the method tests whether a feature matters — the calibrated test MDA lacks. -Preferred-when / avoid-when (regime tag, verbatim from `CONTRIBUTIONS_LEDGER.md`): +Preferred-when / avoid-when guidance: prefer CPI when a statistically valid test of whether a feature matters is needed (MDA has none) — nominal size, good power. diff --git a/src/Features/MicrostructuralFeatures.jl b/src/Features/MicrostructuralFeatures.jl index 7ec2e8c..3ffaa41 100644 --- a/src/Features/MicrostructuralFeatures.jl +++ b/src/Features/MicrostructuralFeatures.jl @@ -161,15 +161,15 @@ spread (`0.01` is a 1% spread). Returns `NaN` when the estimate is undefined zero-variance input). `sign=true` returns a signed estimate (negative when the estimated squared spread is negative). -Preferred-when / avoid-when (regime tag, verbatim from `CONTRIBUTIONS_LEDGER.md`): +Preferred-when / avoid-when guidance: prefer EDGE over Roll and Abdi-Ranaldo for low-frequency spread estimation in all regimes; over Corwin-Schultz at small spreads (the edge narrows at very high illiquidity and very large spreads). Clean-room Julia port of the published algorithm, mirroring the validated Python `RiskLabAI.features.microstructural_features.edge.edge_estimator` reference -(numeric parity asserted in `test/runtests.jl`). Admitted in Appraisal 03 -(`library_extension/appraisals/03_verdict.md`; real-data confirmation a logged +(numeric parity asserted in `test/runtests.jl`). Included after independent validation +(the documented validation evidence; real-data confirmation a logged follow-up pending an adequate public intraday / quote dataset). Reference: Ardia, D., Guidotti, E., & Kroencke, T. A. (2024). Efficient diff --git a/src/Features/StructuralBreaks.jl b/src/Features/StructuralBreaks.jl index f5bb973..1f91147 100644 --- a/src/Features/StructuralBreaks.jl +++ b/src/Features/StructuralBreaks.jl @@ -169,7 +169,7 @@ end # multiple bubbles where SADF sees one. Clean-room from Phillips–Shi–Yu (2015), # building on the validated `get_bsadf_statistic` / `get_expanding_window_adf`; # numeric parity asserted against the Python reference in `test/runtests.jl`. -# Admitted in Appraisal 05 (`library_extension/appraisals/05_verdict.md`). +# Included after independent validation (the documented validation evidence). # --------------------------------------------------------------------------- # """ @@ -309,7 +309,7 @@ flexible windows. A series is flagged as containing at least one explosive episo when the GSADF exceeds its finite-sample critical value (`simulate_psy_critical_values`). Returns `NaN` if the series is too short. -Preferred-when / avoid-when (regime tag, verbatim from `CONTRIBUTIONS_LEDGER.md`): +Preferred-when / avoid-when guidance: prefer GSADF/BSADF over single-window SADF when a series may contain more than one explosive episode (it recovers and counts each). For a single suspected bubble, SADF is at least as good. Use seasonally-adjusted data and the simulated @@ -442,14 +442,14 @@ end # # The admitted SADF / GSADF bubble detector uses a sup-ADF statistic whose null # distribution assumes homoskedastic errors; under non-stationary volatility it -# over-rejects and flags spurious bubbles (the mild oversizing noted in Appraisal +# over-rejects and flags spurious bubbles (the mild oversizing noted during independent validation # 05). The volatility-robust variant computes the SAME sup-ADF statistics but # calibrates the critical values by a wild bootstrap (Rademacher sign-flip of the # first-difference residuals), which preserves the series' own volatility pattern # while destroying any explosive autocorrelation, restoring correct size under # non-stationary volatility. Clean-room from Harvey et al. (2016), reusing the -# validated `_psy_sadf_bsadf_sequences` kernel. Admitted in Appraisal 26 -# (`library_extension/appraisals/26_verdict.md`). +# validated `_psy_sadf_bsadf_sequences` kernel. Included after independent validation +# (the documented validation evidence). # # Stochastic-step divergence (documented): the Rademacher signs are drawn from # Julia's `rng` rather than NumPy's PCG64 stream, so the observed `sadf`/`gsadf` @@ -487,7 +487,7 @@ Returns a `NamedTuple` with `sadf`, `gsadf` (observed statistics, deterministic) `sadf_pvalue`, `gsadf_pvalue` (wild-bootstrap) and `reject_sadf` / `reject_gsadf` (at the 5% level). -Preferred-when / avoid-when (regime tag, verbatim from `appraisals/26_verdict.md`): +Preferred-when / avoid-when guidance: prefer it over plain SADF/GSADF when the series' volatility may be non-stationary: it holds nominal size where plain SADF over-rejects ~9×, at a modest power cost, and converges to plain SADF under constant volatility. Pairs with the admitted @@ -550,8 +550,8 @@ end # al. (2012); the segment cost and the candidate grid replicate the BSD-2 `ruptures` # reference (`CostNormal` + `Pelt`) used by the Python implementation, so the two # return the same change-point indices on a shared series (parity asserted in -# `test/runtests.jl`). Admitted in Appraisal 26 -# (`library_extension/appraisals/26_verdict.md`). +# `test/runtests.jl`). Included after independent validation +# (the documented validation evidence). # --------------------------------------------------------------------------- # # Gaussian (mean+variance) segment cost, mirroring `ruptures.CostNormal` for a 1-D @@ -649,7 +649,7 @@ variance changes, so PELT recovers multiple and variance change-points that CUSU misses, without over-segmenting under an adequate penalty (`model="l2"` is mean only). -Preferred-when / avoid-when (regime tag, verbatim from `appraisals/26_verdict.md`): +Preferred-when / avoid-when guidance: prefer it over CUSUM for detecting and dating multiple and/or variance change-points (which CUSUM misses), without over-segmenting; CUSUM remains adequate for a single mean shift. diff --git a/src/Validation/HyperParameterTuning.jl b/src/Validation/HyperParameterTuning.jl index 9680d7f..f6bfc3b 100644 --- a/src/Validation/HyperParameterTuning.jl +++ b/src/Validation/HyperParameterTuning.jl @@ -120,7 +120,7 @@ end # --------------------------------------------------------------------------- # # Leakage-aware HPO methodology (Akiba 2019 Optuna + de Prado purged CV / DSR). -# Admitted in Appraisal 20 as methodology/infrastructure, NOT a performance claim: +# Included after independent validation as methodology/infrastructure, NOT a performance claim: # principled search reaches the optimum in fewer trials and purged CV removes the # leakage a naive k-fold inflates, but tuning yields NO out-of-sample edge after # deflation, so the selected model must be gated by the Deflated Sharpe at the HPO @@ -130,9 +130,9 @@ end # Deliberate divergence: the Optuna TPE/CMA-ES sampler is an OPTIONAL analogue not # bundled in the Julia port (no de-facto Optuna). `leakage_aware_hpo` wires random # sampling through the repo's `PurgedKFoldCV` (the leakage-controlled per-trial -# score) — the admitted methodology — and the DSR gate; a Bayesian/evolutionary -# sampler can be substituted where available. Appraisal 20 -# (`library_extension/appraisals/20_verdict.md`). +# score) — the included methodology — and the DSR gate; a Bayesian/evolutionary +# sampler can be substituted where available. independent validation +# (the documented validation evidence). # --------------------------------------------------------------------------- # using Statistics: std @@ -147,7 +147,7 @@ Leakage-aware hyper-parameter search: every sampled configuration is scored unde `NamedTuple` `(best_params, best_score, n_trials, trial_scores, mean_trial_score, std_trial_score)`. -Preferred-when / avoid-when (regime tag, verbatim from `CONTRIBUTIONS_LEDGER.md`): +Preferred-when / avoid-when guidance: use leakage-aware HPO (Optuna wired through PurgedKFold/CPCV, selection gated by PBO/DSR at the HPO trial count) as the correct, efficient, leakage-safe tuning methodology — preferred over grid/random for search efficiency and over naive-CV @@ -197,7 +197,7 @@ cross-trial Sharpe dispersion `trial_sharpe_std`), and the Deflated Sharpe is th probability the realized OOS Sharpe exceeds it. A selection passes only if the Deflated Sharpe exceeds `threshold`. Returns a `NamedTuple` `(observed_sharpe, benchmark_sharpe, n_trials, deflated_sharpe, passes)`. This is the decisive control: -in the appraisal the principled-HPO gain did not pass it. Mirrors Python's +during independent validation the principled-HPO gain did not pass it. Mirrors Python's `deflated_sharpe_gate`. """ function deflated_sharpe_gate( diff --git a/src/Validation/PathAdaptiveCPCV.jl b/src/Validation/PathAdaptiveCPCV.jl index 83f54cf..4b1f177 100644 --- a/src/Validation/PathAdaptiveCPCV.jl +++ b/src/Validation/PathAdaptiveCPCV.jl @@ -13,9 +13,7 @@ This is the path-level mechanism; it does not touch the existing split-boundary adaptive cross-validator. The per-path overfit indicator comes from `Backtest.performance_evaluation`, so with uniform weights it reproduces `Backtest.probability_of_backtest_overfitting` exactly. The port is fully -deterministic (no RNG); numeric parity asserted in `test/runtests.jl`. Admitted in -Appraisal 09b (`library_extension/appraisals/09_verdict.md`; in-house method, COI, -real-data regime-shift confirmation a tracked obligation). +deterministic (no RNG); numeric parity asserted in `test/runtests.jl`. Included after independent validation (the documented validation evidence; method extension; real-data regime-shift confirmation remains future validation work). Reference: Arian, H., Norouzi, M. L. & Seco, L. (2024). Bagged and Adaptive Combinatorial Purged Cross-Validation. Bailey, Borwein, López de Prado & Zhu @@ -122,7 +120,7 @@ the sample). Returns `(regime_weighted_pbo, selected_config)` with `selected_con the 1-based index of the configuration with the highest regime-weighted OOS metric. In a stationary regime the weights are uniform, so the PBO converges to plain CPCV. -Preferred-when / avoid-when (regime tag, verbatim from `CONTRIBUTIONS_LEDGER.md`): +Preferred-when / avoid-when guidance: prefer Adaptive CPCV over plain CPCV for model selection when the train/test regime may shift, the shift is identifiable at decision time from observable volatility, and there is adequate data for selection (it does not help where selection is diff --git a/src/Validation/PathBaggedCPCV.jl b/src/Validation/PathBaggedCPCV.jl index 2fafcca..86454e5 100644 --- a/src/Validation/PathBaggedCPCV.jl +++ b/src/Validation/PathBaggedCPCV.jl @@ -14,9 +14,8 @@ CSCV PBO (`Backtest.probability_of_backtest_overfitting`), called on each resamp Deliberate divergence (behavioural): the moving-block bootstrap uses Julia's `rng` rather than NumPy's PCG64 stream, so the bagged PBO is reproducible under a given `rng` but not bit-identical to the Python reference (the plain CSCV PBO it builds -on is parity-tested exactly). Admitted in Appraisal 09 -(`library_extension/appraisals/09_verdict.md`; in-house method, COI, held to the -identical bar). +on is parity-tested exactly). Included after independent validation +(the documented validation evidence; method extension validated independently). Reference: Arian, H., Norouzi, M. L. & Seco, L. (2024). Bagged and Adaptive Combinatorial Purged Cross-Validation. Bailey, Borwein, López de Prado & Zhu @@ -59,7 +58,7 @@ bootstrap resamples of the `T×N` `performances` matrix, computes the CSCV PBO o each, and returns `(bagged_pbo, per_resample_pbos)` — a lower-variance estimate than a single CPCV PBO. `block_size` defaults to `max(T ÷ 20, 5)`. -Preferred-when / avoid-when (regime tag, verbatim from `CONTRIBUTIONS_LEDGER.md`): +Preferred-when / avoid-when guidance: prefer path-level Bagged CPCV over plain CPCV for a more accurate, lower-variance overfitting (PBO) estimate whenever the CPCV path set is small or noisy; it converges to plain CPCV in the data-rich limit and is neutral on which model is diff --git a/test/causal_factor_analysis/fixtures/additive_numeric_parity.tsv b/test/causal_factor_analysis/fixtures/additive_numeric_parity.tsv new file mode 100644 index 0000000..09b5877 --- /dev/null +++ b/test/causal_factor_analysis/fixtures/additive_numeric_parity.tsv @@ -0,0 +1,14 @@ +case_id method arg1 arg2 arg3 arg4 arg5 arg6 arg7 arg8 expected1 expected2 expected3 +mirage_confounder_general generalized_confounder 2 3 4 5 6 2.697674418604651 +mirage_collider_general generalized_collider 2 3 4 5 6 -0.9411764705882353 0.29411764705882354 +fdr_single_source_b single_trial_fdr 0.024997895148220373 0.9515427737332771 0.15 0.08344067427559433 +fdr_max_source_a max_selection_errors 0.024997895148220373 0.9515427737332771 0.95 10 0.5987369392383787 0.22365361940347483 0.7531960884251255 +fdr_max_sensitivity max_selection_errors 0.05 0.9 0.9 5 0.5904900000000001 0.2262190625000002 0.7245771630882027 +maximum_mixture_simple maximum_mixture_cdf 0.4 0.8 0.25 3 0.343 +conditional_tail_simple conditional_upper_tail 0.75 0.25 0.3333333333333333 +gaussian_identical_cdf gaussian_max_cdf 0.37 1 0 1 0 3 0.125 +gaussian_identical_log_density gaussian_max_log_density 0.37 1 0 1 0 3 -1.2066206056564532 +nonidentification_simple nonidentification_witness 0.36 0.49 0.7 0.6 0.49 +selection_exponential selection_exponential 0.4 0.63 5 0.9961054013913372 0.6275464028765424 0.63 +allocation_two_asset allocation_two_asset 0.5 -0.5 -1 + diff --git a/test/causal_factor_analysis/test_additive_numeric_parity.jl b/test/causal_factor_analysis/test_additive_numeric_parity.jl new file mode 100644 index 0000000..9e56a39 --- /dev/null +++ b/test/causal_factor_analysis/test_additive_numeric_parity.jl @@ -0,0 +1,145 @@ +@testset "additive numeric cross-language fixture" begin + fixture_path = joinpath(@__DIR__, "fixtures", "additive_numeric_parity.tsv") + rows = readlines(fixture_path) + header = split(first(rows), '\t'; keepempty = true) + cases = [ + Dict(zip(header, split(row, '\t'; keepempty = true))) for + row in Iterators.drop(rows, 1) if !isempty(row) + ] + @test [case["case_id"] for case in cases] == [ + "mirage_confounder_general", + "mirage_collider_general", + "fdr_single_source_b", + "fdr_max_source_a", + "fdr_max_sensitivity", + "maximum_mixture_simple", + "conditional_tail_simple", + "gaussian_identical_cdf", + "gaussian_identical_log_density", + "nonidentification_simple", + "selection_exponential", + "allocation_two_asset", + ] + + number(case, name) = parse(Float64, case[name]) + for case in cases + @testset "$(case["case_id"])" begin + method = case["method"] + if method == "generalized_confounder" + actual = generalized_confounder_undercontrolled_coefficient( + number(case, "arg1"), + number(case, "arg2"), + number(case, "arg3"); + confounder_variance = number(case, "arg4"), + exposure_noise_variance = number(case, "arg5"), + ) + @test actual ≈ number(case, "expected1") atol = 1e-15 + elseif method == "generalized_collider" + actual = generalized_collider_overcontrolled_coefficients( + number(case, "arg1"), + number(case, "arg2"), + number(case, "arg3"); + outcome_noise_variance = number(case, "arg4"), + collider_noise_variance = number(case, "arg5"), + ) + @test actual.beta_hat ≈ number(case, "expected1") atol = 1e-15 + @test actual.theta_hat ≈ number(case, "expected2") atol = 1e-15 + elseif method == "single_trial_fdr" + actual = single_trial_false_discovery_rate( + number(case, "arg1"), + number(case, "arg2"), + number(case, "arg3"), + ) + @test actual ≈ number(case, "expected1") atol = 2e-15 + elseif method == "max_selection_errors" + actual = max_selection_family_errors( + number(case, "arg1"), + number(case, "arg2"), + number(case, "arg3"), + Int(number(case, "arg4")), + ) + @test actual.family_null_probability ≈ number(case, "expected1") atol = + 2e-15 + @test actual.family_type_i_error ≈ number(case, "expected2") atol = 2e-15 + @test actual.family_type_ii_error ≈ number(case, "expected3") atol = 2e-14 + elseif method == "maximum_mixture_cdf" + actual = maximum_mixture_cdf( + number(case, "arg1"), + number(case, "arg2"), + number(case, "arg3"), + Int(number(case, "arg4")), + ) + @test actual ≈ number(case, "expected1") atol = 1e-15 + elseif method == "conditional_upper_tail" + actual = conditional_upper_tail_probability( + number(case, "arg1"), + number(case, "arg2"), + ) + @test actual ≈ number(case, "expected1") atol = 1e-15 + elseif method in ("gaussian_max_cdf", "gaussian_max_log_density") + model = GaussianTrialMixture( + number(case, "arg1"), + number(case, "arg2"), + number(case, "arg3"), + number(case, "arg4"), + ) + actual = if method == "gaussian_max_cdf" + gaussian_max_selection_cdf( + model, + number(case, "arg5"), + Int(number(case, "arg6")), + ) + else + gaussian_max_selection_log_density( + model, + number(case, "arg5"), + Int(number(case, "arg6")), + ) + end + @test actual ≈ number(case, "expected1") atol = 2e-15 + elseif method == "nonidentification_witness" + actual = fdr_nonidentification_witness( + number(case, "arg1"), + number(case, "arg2"), + ) + @test actual.trial_null_probability ≈ number(case, "expected1") atol = 1e-15 + @test actual.latent_trial_cdf_at_threshold ≈ number(case, "expected2") atol = + 1e-15 + @test actual.family_false_discovery_rate ≈ number(case, "expected3") atol = + 1e-15 + elseif method == "selection_exponential" + density(value) = value >= 0.0 ? exp(-value) : 0.0 + distribution(value) = value > 0.0 ? 1.0 - exp(-value) : 0.0 + actual = max_selection_null_probability( + number(case, "arg1"), + number(case, "arg2"), + Int(number(case, "arg3")); + null_density = density, + mixture_cdf = distribution, + ) + @test actual.selection_probability ≈ number(case, "expected1") atol = 2e-14 + @test actual.joint_null_and_selection_probability ≈ + number(case, "expected2") atol = 2e-11 + @test actual.conditional_null_probability ≈ number(case, "expected3") atol = + 2e-11 + elseif method == "allocation_two_asset" + actual = allocation_misspecification_diagnostics( + Matrix{Float64}(I, 2, 2), + reshape([1.0, 1.0], 2, 1), + [1.0], + reshape([1.0, -1.0], 2, 1), + [1.0], + ) + @test actual.reference_weights ≈ fill(number(case, "expected1"), 2) atol = + 1e-14 + @test actual.misspecified_weights ≈ + [number(case, "expected1"), number(case, "expected2")] atol = 1e-14 + @test actual.reference_exposure_error ≈ [number(case, "expected3")] atol = + 1e-14 + @test actual.weight_sign_reversals == [false, true] + else + error("unhandled parity method: $method") + end + end + end +end diff --git a/test/causal_factor_analysis/test_allocation_diagnostics.jl b/test/causal_factor_analysis/test_allocation_diagnostics.jl new file mode 100644 index 0000000..277fa79 --- /dev/null +++ b/test/causal_factor_analysis/test_allocation_diagnostics.jl @@ -0,0 +1,321 @@ +using LinearAlgebra +using Random +using Test + +function _allocation_kkt_weights(covariance, factor_exposures, target_exposures) + n_assets, n_factors = size(factor_exposures) + system = [ + 2.0 .* covariance factor_exposures + transpose(factor_exposures) zeros(n_factors, n_factors) + ] + right_hand_side = vcat(zeros(n_assets), target_exposures) + return (system\right_hand_side)[1:n_assets] +end + +function _allocation_example_inputs() + covariance = Diagonal([1.0, 2.0, 4.0]) |> Matrix + reference_factor_exposures = [1.0 0.0; 0.0 1.0; 1.0 1.0] + reference_target_exposures = [0.0, 1.0] + misspecified_factor_exposures = reshape([1.0, 1.0, -2.0], 3, 1) + misspecified_target_exposures = [1.0] + return ( + covariance, + reference_factor_exposures, + reference_target_exposures, + misspecified_factor_exposures, + misspecified_target_exposures, + ) +end + +@testset "allocation misspecification diagnostics" begin + @test fieldnames(AllocationMisspecificationDiagnostics) == ( + :reference_weights, + :misspecified_weights, + :reference_target_exposures, + :reference_achieved_exposures, + :misspecified_reference_exposures, + :misspecified_model_target_exposures, + :misspecified_model_achieved_exposures, + :reference_exposure_error, + :weight_sign_reversals, + :reference_variance, + :misspecified_variance, + ) + @test fieldtypes(AllocationMisspecificationDiagnostics) == ( + Vector{Float64}, + Vector{Float64}, + Vector{Float64}, + Vector{Float64}, + Vector{Float64}, + Vector{Float64}, + Vector{Float64}, + Vector{Float64}, + Vector{Bool}, + Float64, + Float64, + ) + + @testset "independent KKT, exposure, sign, and variance oracles" begin + inputs = _allocation_example_inputs() + covariance, + reference_exposures, + reference_target, + misspecified_exposures, + misspecified_target = inputs + result = allocation_misspecification_diagnostics(inputs...) + + reference_weights = + _allocation_kkt_weights(covariance, reference_exposures, reference_target) + misspecified_weights = + _allocation_kkt_weights(covariance, misspecified_exposures, misspecified_target) + reference_achieved = transpose(reference_exposures) * reference_weights + misspecified_reference = transpose(reference_exposures) * misspecified_weights + misspecified_model_achieved = + transpose(misspecified_exposures) * misspecified_weights + + @test result isa AllocationMisspecificationDiagnostics + @test isimmutable(result) + @test result.reference_weights ≈ reference_weights atol = 2.0e-14 rtol = 0.0 + @test result.misspecified_weights ≈ misspecified_weights atol = 2.0e-14 rtol = 0.0 + @test result.reference_target_exposures == reference_target + @test result.reference_achieved_exposures ≈ reference_achieved atol = 2.0e-14 + @test result.misspecified_reference_exposures ≈ misspecified_reference atol = + 2.0e-14 + @test result.misspecified_model_target_exposures == misspecified_target + @test result.misspecified_model_achieved_exposures ≈ misspecified_model_achieved atol = + 2.0e-14 + @test result.reference_exposure_error ≈ misspecified_reference - reference_target atol = + 2.0e-14 + @test result.weight_sign_reversals == [true, false, true] + @test result.reference_variance ≈ + dot(reference_weights, covariance, reference_weights) atol = 2.0e-14 + @test result.misspecified_variance ≈ + dot(misspecified_weights, covariance, misspecified_weights) atol = 2.0e-14 + + reference_stationarity = 2.0 .* covariance * reference_weights + reference_multiplier = -(reference_exposures \ reference_stationarity) + @test reference_stationarity + reference_exposures * reference_multiplier ≈ + zeros(length(reference_weights)) atol = 5.0e-14 + end + + @testset "declared constraints and strict sign reversal" begin + result = allocation_misspecification_diagnostics(_allocation_example_inputs()...) + @test result.reference_achieved_exposures ≈ result.reference_target_exposures atol = + 2.0e-14 + @test result.misspecified_model_achieved_exposures ≈ + result.misspecified_model_target_exposures atol = 2.0e-14 + @test !isapprox( + result.misspecified_reference_exposures, + result.reference_target_exposures; + atol = 1.0e-12, + ) + @test result.reference_exposure_error ≈ [0.2, -1.0] atol = 2.0e-14 + + identity_covariance = Matrix{Float64}(I, 2, 2) + identity_exposures = Matrix{Float64}(I, 2, 2) + strict = allocation_misspecification_diagnostics( + identity_covariance, + identity_exposures, + [1.0, 0.0], + identity_exposures, + [-1.0, 0.0], + ) + zero_boundary = allocation_misspecification_diagnostics( + identity_covariance, + identity_exposures, + [0.0, 1.0], + identity_exposures, + [-1.0, 1.0], + ) + @test strict.weight_sign_reversals == [true, false] + @test zero_boundary.weight_sign_reversals == [false, false] + + negative_zero_boundary = allocation_misspecification_diagnostics( + identity_covariance, + identity_exposures, + [-0.0, 1.0], + identity_exposures, + [1.0, 1.0], + ) + @test negative_zero_boundary.weight_sign_reversals == [false, false] + end + + @testset "covariance units and independent snapshots" begin + inputs = _allocation_example_inputs() + baseline = allocation_misspecification_diagnostics(inputs...) + rescaled = allocation_misspecification_diagnostics( + 7.5 .* inputs[1], + inputs[2], + inputs[3], + inputs[4], + inputs[5], + ) + @test rescaled.reference_weights ≈ baseline.reference_weights + @test rescaled.misspecified_weights ≈ baseline.misspecified_weights + @test rescaled.reference_variance ≈ 7.5 * baseline.reference_variance rtol = 2.0e-14 + @test rescaled.misspecified_variance ≈ 7.5 * baseline.misspecified_variance rtol = + 2.0e-14 + + mutable_inputs = map(copy, inputs) + result = allocation_misspecification_diagnostics(mutable_inputs...) + snapshots = ( + copy(result.reference_weights), + copy(result.misspecified_weights), + copy(result.reference_target_exposures), + copy(result.reference_achieved_exposures), + copy(result.misspecified_reference_exposures), + copy(result.misspecified_model_target_exposures), + copy(result.misspecified_model_achieved_exposures), + copy(result.reference_exposure_error), + copy(result.weight_sign_reversals), + ) + for input in mutable_inputs + fill!(input, 99.0) + end + @test result.reference_weights == snapshots[1] + @test result.misspecified_weights == snapshots[2] + @test result.reference_target_exposures == snapshots[3] + @test result.reference_achieved_exposures == snapshots[4] + @test result.misspecified_reference_exposures == snapshots[5] + @test result.misspecified_model_target_exposures == snapshots[6] + @test result.misspecified_model_achieved_exposures == snapshots[7] + @test result.reference_exposure_error == snapshots[8] + @test result.weight_sign_reversals == snapshots[9] + + field_vectors = ( + result.reference_weights, + result.misspecified_weights, + result.reference_target_exposures, + result.reference_achieved_exposures, + result.misspecified_reference_exposures, + result.misspecified_model_target_exposures, + result.misspecified_model_achieved_exposures, + result.reference_exposure_error, + result.weight_sign_reversals, + ) + @test all( + field_vectors[left] !== field_vectors[right] for + left in eachindex(field_vectors) for right = (left+1):length(field_vectors) + ) + end + + @testset "seeded random KKT systems" begin + rng = MersenneTwister(15485863) + for _ = 1:40 + covariance_root = randn(rng, 5, 5) + covariance = + transpose(covariance_root) * covariance_root + + 0.5 .* Matrix{Float64}(I, 5, 5) + reference_exposures = randn(rng, 5, 2) + reference_target = randn(rng, 2) + misspecified_exposures = randn(rng, 5, 3) + misspecified_target = randn(rng, 3) + + result = allocation_misspecification_diagnostics( + covariance, + reference_exposures, + reference_target, + misspecified_exposures, + misspecified_target, + ) + reference_oracle = + _allocation_kkt_weights(covariance, reference_exposures, reference_target) + misspecified_oracle = _allocation_kkt_weights( + covariance, + misspecified_exposures, + misspecified_target, + ) + + @test result.reference_weights ≈ reference_oracle rtol = 5.0e-11 atol = 5.0e-12 + @test result.misspecified_weights ≈ misspecified_oracle rtol = 5.0e-11 atol = + 5.0e-12 + @test result.reference_achieved_exposures ≈ reference_target rtol = 5.0e-11 atol = + 5.0e-12 + @test result.misspecified_model_achieved_exposures ≈ misspecified_target rtol = + 5.0e-11 atol = 5.0e-12 + @test result.reference_variance ≈ + dot(reference_oracle, covariance, reference_oracle) rtol = 5.0e-11 + @test result.misspecified_variance ≈ + dot(misspecified_oracle, covariance, misspecified_oracle) rtol = 5.0e-11 + end + end + + @testset "public boundary validation" begin + base_inputs = _allocation_example_inputs() + invalid_replacements = ( + (1, Bool[true false; false true]), + (1, [1.0 0.0 0.0; 0.0 NaN 0.0; 0.0 0.0 1.0]), + (1, Diagonal([1.0, 2.0, 0.0]) |> Matrix), + (2, ComplexF64[1.0 + 1.0im 0.0; 0.0 1.0; 1.0 1.0]), + (2, ones(2, 2)), + (3, reshape(ones(2), 2, 1)), + (4, ones(3, 4)), + (5, ones(2)), + ) + for (replacement_index, replacement) in invalid_replacements + inputs = Any[base_inputs...] + inputs[replacement_index] = replacement + @test_throws ArgumentError allocation_misspecification_diagnostics(inputs...) + end + + covariance, reference, target, misspecified, misspecified_target = base_inputs + redundant_reference = hcat(reference[:, 1], reference[:, 1]) + @test_throws ArgumentError allocation_misspecification_diagnostics( + covariance, + redundant_reference, + target, + misspecified, + misspecified_target, + ) + @test_throws ArgumentError allocation_misspecification_diagnostics( + zeros(0, 0), + zeros(0, 1), + [1.0], + zeros(0, 1), + [1.0], + ) + @test_throws ArgumentError allocation_misspecification_diagnostics( + covariance, + zeros(3, 0), + Float64[], + misspecified, + misspecified_target, + ) + @test_throws ArgumentError allocation_misspecification_diagnostics( + reshape([1.0e308], 1, 1), + reshape([1.0], 1, 1), + [2.0], + reshape([1.0], 1, 1), + [2.0], + ) + end + + @testset "direct evidence construction copies every vector" begin + vectors = [Float64[index, -index] for index = 1:8] + reversals = [true, false] + result = AllocationMisspecificationDiagnostics(vectors..., reversals, 1.0, 2.0) + expected = map(copy, vectors) + expected_reversals = copy(reversals) + for vector in vectors + fill!(vector, 99.0) + end + fill!(reversals, false) + + @test result.reference_weights == expected[1] + @test result.misspecified_weights == expected[2] + @test result.reference_target_exposures == expected[3] + @test result.reference_achieved_exposures == expected[4] + @test result.misspecified_reference_exposures == expected[5] + @test result.misspecified_model_target_exposures == expected[6] + @test result.misspecified_model_achieved_exposures == expected[7] + @test result.reference_exposure_error == expected[8] + @test result.weight_sign_reversals == expected_reversals + end + + @testset "inputs are not mutated" begin + inputs = _allocation_example_inputs() + snapshots = map(copy, inputs) + allocation_misspecification_diagnostics(inputs...) + @test all(observed == expected for (observed, expected) in zip(inputs, snapshots)) + end +end diff --git a/test/causal_factor_analysis/test_false_discovery_rates.jl b/test/causal_factor_analysis/test_false_discovery_rates.jl new file mode 100644 index 0000000..6d3c17a --- /dev/null +++ b/test/causal_factor_analysis/test_false_discovery_rates.jl @@ -0,0 +1,260 @@ +using Random + +@testset "false-discovery rates" begin + @testset "single and family Bayes identities" begin + alpha = 0.05 + beta = 0.20 + pi_zero = 0.80 + expected_single = + alpha * pi_zero / (alpha * pi_zero + (1.0 - beta) * (1.0 - pi_zero)) + @test single_trial_false_discovery_rate(alpha, beta, pi_zero) ≈ expected_single atol = + 1e-15 + + alpha_family = 0.15 + beta_family = 0.40 + n_trials = 3 + family_null = pi_zero^n_trials + expected_family = + alpha_family * family_null / + (alpha_family * family_null + (1.0 - beta_family) * (1.0 - family_null)) + @test family_level_false_discovery_rate( + alpha_family, + beta_family, + pi_zero, + n_trials, + ) ≈ expected_family atol = 1e-15 + + @test single_trial_false_discovery_rate(0.0, 0.0, 0.5) == 0.0 + @test single_trial_false_discovery_rate(1.0, 1.0, 0.5) == 1.0 + @test_throws ArgumentError single_trial_false_discovery_rate(0.0, 1.0, 0.5) + @test_throws ArgumentError family_level_false_discovery_rate(0.0, 1.0, 0.5, 4) + end + + @testset "maximum-selection family errors" begin + alpha = 0.024997895148220373 + beta = 0.9515427737332771 + evidence = max_selection_family_errors(alpha, beta, 0.95, 10) + @test evidence.n_trials == 10 + @test evidence.family_null_probability ≈ 0.5987369392383787 atol = 1e-15 + @test evidence.family_type_i_error ≈ 0.22365361940347483 atol = 1e-15 + @test evidence.family_type_ii_error ≈ 0.7531960884251256 atol = 1e-14 + @test family_level_false_discovery_rate( + evidence.family_type_i_error, + evidence.family_type_ii_error, + evidence.trial_null_probability, + evidence.n_trials, + ) ≈ 0.5748603608683863 atol = 2e-15 + + sensitivity = max_selection_family_errors(0.05, 0.90, 0.90, 5) + @test sensitivity.family_null_probability ≈ 0.5904900000000001 atol = 1e-15 + @test sensitivity.family_type_i_error ≈ 0.22621906250000023 atol = 1e-15 + @test sensitivity.family_type_ii_error ≈ 0.7245771630882027 atol = 1e-14 + @test family_level_false_discovery_rate( + sensitivity.family_type_i_error, + sensitivity.family_type_ii_error, + 0.90, + 5, + ) ≈ 0.5421963202650197 atol = 2e-15 + + direct_alpha = 1.0 - (1.0 - 0.12)^4 + mixture_below = 0.7 * (1.0 - 0.12) + 0.3 * 0.65 + direct_beta = (mixture_below^4 - (0.7 * (1.0 - 0.12))^4) / (1.0 - 0.7^4) + direct = max_selection_family_errors(0.12, 0.65, 0.7, 4) + @test direct.family_type_i_error ≈ direct_alpha atol = 1e-15 + @test direct.family_type_ii_error ≈ direct_beta atol = 1e-15 + + stable = max_selection_family_errors(1e-12, 1.0 - 1e-12, 0.999, 1_000_000) + @test 0.0 <= stable.family_type_i_error <= 1.0 + @test 0.0 <= stable.family_type_ii_error <= 1.0 + @test_throws ArgumentError max_selection_family_errors(0.1, 0.2, 0.0, 2) + @test_throws ArgumentError max_selection_family_errors(0.1, 0.2, 1.0, 2) + end + + @testset "identification comparison" begin + alpha = 0.05 + beta = 0.20 + pi_zero = 0.80 + n_trials = 3 + beta_family = 0.40 + required_ratio = + (1.0 - beta_family) / (1.0 - beta) * (1.0 - pi_zero^n_trials) / + (pi_zero^(n_trials - 1) * (1.0 - pi_zero)) + alpha_family = alpha * required_ratio + evidence = compare_single_and_family_fdr( + alpha, + beta, + pi_zero, + n_trials; + family_type_i_error = alpha_family, + family_type_ii_error = beta_family, + ) + @test evidence.identification_condition_holds + @test evidence.single_trial_upper_bounds_family + @test abs(evidence.equation_13_log_gap) <= 1e-14 + + violated = compare_single_and_family_fdr( + alpha, + beta, + pi_zero, + n_trials; + family_type_i_error = alpha_family / 2, + family_type_ii_error = beta_family, + ) + @test !violated.identification_condition_holds + @test violated.single_trial_upper_bounds_family + end + + @testset "CDF primitives" begin + @test maximum_mixture_cdf(0.4, 0.8, 0.25, 3) ≈ (0.25 * 0.4 + 0.75 * 0.8)^3 atol = + 1e-15 + @test maximum_mixture_cdf(0.0, 0.0, 0.3, 5) == 0.0 + @test maximum_mixture_cdf(1.0, 1.0, 0.3, 5) == 1.0 + @test conditional_upper_tail_probability(0.75, 0.25) ≈ 1 / 3 atol = 1e-15 + @test conditional_upper_tail_probability(1.0, 0.25) == 0.0 + @test_throws ArgumentError conditional_upper_tail_probability(0.2, 0.3) + @test_throws ArgumentError conditional_upper_tail_probability(1.0, 1.0) + end + + @testset "Gaussian maximum model" begin + identical = GaussianTrialMixture(0.37, 1.0, 0.0, 1.0) + @test gaussian_trial_mixture_cdf(identical, 0.0) ≈ 0.5 atol = 1e-15 + @test gaussian_max_selection_cdf(identical, 0.0, 3) ≈ 0.125 atol = 1e-15 + expected_log_density = log(3.0) + 2.0 * log(0.5) - 0.5 * log(2.0 * pi) + @test gaussian_max_selection_log_density(identical, 0.0, 3) ≈ expected_log_density atol = + 1e-14 + + observations = [-1.0, 0.0, 0.5, 2.0] + expected_likelihood = sum( + gaussian_max_selection_log_density(identical, value, 7) for + value in observations + ) + @test gaussian_max_selection_log_likelihood(identical, observations, 7) ≈ + expected_likelihood atol = 1e-14 + + asymmetric = GaussianTrialMixture(0.72, 1.3, 0.9, 0.65) + point = 0.4 + step = 1e-5 + derivative = + ( + gaussian_max_selection_cdf(asymmetric, point + step, 5) - + gaussian_max_selection_cdf(asymmetric, point - step, 5) + ) / (2.0 * step) + @test exp(gaussian_max_selection_log_density(asymmetric, point, 5)) ≈ derivative rtol = + 2e-9 atol = 1e-12 + + thresholds = [-0.25, 0.5, 1.25] + adjusted = gaussian_search_adjusted_false_discovery_rate(asymmetric, thresholds, 4) + @test adjusted.n_trials == 4 + @test adjusted.thresholds == thresholds + @test all(0.0 .<= adjusted.family_type_i_errors .<= 1.0) + @test all(0.0 .<= adjusted.family_type_ii_errors .<= 1.0) + @test adjusted.mean_family_type_i_error ≈ mean(adjusted.family_type_i_errors) + @test adjusted.mean_family_type_ii_error ≈ mean(adjusted.family_type_ii_errors) + @test adjusted.family_null_probability ≈ 0.72^4 atol = 1e-15 + copied_thresholds = copy(thresholds) + thresholds[1] = 99.0 + @test adjusted.thresholds == copied_thresholds + + @test_throws ArgumentError GaussianTrialMixture(0.5, 0.0, 1.0, 1.0) + @test_throws ArgumentError GaussianTrialMixture(0.5, 1.0, Inf, 1.0) + @test_throws ArgumentError gaussian_max_selection_log_likelihood( + identical, + Float64[], + 2, + ) + @test_throws ArgumentError gaussian_search_adjusted_false_discovery_rate( + identical, + Float64[], + 2, + ) + end + + @testset "unrestricted nonidentification witness" begin + for observable_cdf in (0.05, 0.35, 0.90), target_fdr in (0.10, 0.55, 0.95) + witness = fdr_nonidentification_witness(observable_cdf, target_fdr) + @test witness.n_trials == 2 + @test witness.family_false_discovery_rate ≈ target_fdr atol = 2e-15 + @test witness.reconstructed_observable_cdf_at_threshold ≈ observable_cdf atol = + 2e-15 + @test witness.trial_null_probability^2 ≈ target_fdr atol = 2e-15 + end + @test_throws ArgumentError fdr_nonidentification_witness(0.0, 0.5) + @test_throws ArgumentError fdr_nonidentification_witness(0.5, 1.0) + end + + @testset "selected-winner null probability" begin + threshold = 0.4 + n_trials = 5 + pi_zero = 0.63 + exponential_density(x) = x >= 0.0 ? exp(-x) : 0.0 + exponential_cdf(x) = x <= 0.0 ? 0.0 : 1.0 - exp(-x) + evidence = max_selection_null_probability( + threshold, + pi_zero, + n_trials; + null_density = exponential_density, + mixture_cdf = exponential_cdf, + ) + expected_selection = 1.0 - exponential_cdf(threshold)^n_trials + @test evidence.selection_probability ≈ expected_selection atol = 1e-14 + @test evidence.joint_null_and_selection_probability ≈ pi_zero * expected_selection atol = + 2e-11 + @test evidence.conditional_null_probability ≈ pi_zero atol = 2e-11 + @test evidence.quadrature_absolute_error >= 0.0 + + no_null = max_selection_null_probability( + threshold, + 0.0, + n_trials; + null_density = exponential_density, + mixture_cdf = exponential_cdf, + ) + @test no_null.conditional_null_probability == 0.0 + @test_throws ArgumentError max_selection_null_probability( + threshold, + 0.5, + n_trials; + null_density = x -> -1.0, + mixture_cdf = exponential_cdf, + ) + @test_throws ArgumentError max_selection_null_probability( + threshold, + 0.5, + n_trials; + null_density = exponential_density, + mixture_cdf = x -> 1.1, + ) + end + + @testset "randomized direct-formula properties" begin + rng = MersenneTwister(928_441) + for _ = 1:250 + alpha = 0.001 + 0.998 * rand(rng) + beta = 0.001 + 0.998 * rand(rng) + pi_zero = 0.001 + 0.998 * rand(rng) + n_trials = rand(rng, 1:20) + evidence = max_selection_family_errors(alpha, beta, pi_zero, n_trials) + mixture_below = pi_zero * (1.0 - alpha) + (1.0 - pi_zero) * beta + direct_beta = + (mixture_below^n_trials - (pi_zero * (1.0 - alpha))^n_trials) / + (1.0 - pi_zero^n_trials) + @test evidence.family_null_probability ≈ pi_zero^n_trials rtol = 2e-14 + @test evidence.family_type_i_error ≈ 1.0 - (1.0 - alpha)^n_trials rtol = 2e-13 atol = + 2e-15 + @test evidence.family_type_ii_error ≈ direct_beta rtol = 2e-13 atol = 2e-15 + end + end + + @testset "boundary validation" begin + @test_throws ArgumentError single_trial_false_discovery_rate(-0.1, 0.2, 0.5) + @test_throws ArgumentError single_trial_false_discovery_rate(0.1, 1.2, 0.5) + @test_throws ArgumentError single_trial_false_discovery_rate(0.1, 0.2, NaN) + @test_throws ArgumentError family_level_false_discovery_rate(0.1, 0.2, 0.5, 0) + @test_throws ArgumentError family_level_false_discovery_rate(0.1, 0.2, 0.5, true) + @test_throws ArgumentError maximum_mixture_cdf(0.2, 0.3, 0.5, 1.5) + @test_throws ArgumentError gaussian_trial_mixture_cdf( + GaussianTrialMixture(0.5, 1.0, 1.0, 1.0), + Inf, + ) + end +end diff --git a/test/causal_factor_analysis/test_generalized_factor_mirage.jl b/test/causal_factor_analysis/test_generalized_factor_mirage.jl new file mode 100644 index 0000000..2bc7a99 --- /dev/null +++ b/test/causal_factor_analysis/test_generalized_factor_mirage.jl @@ -0,0 +1,242 @@ +using LinearAlgebra +using Random +using Test + +_mirage_fraction(value) = Rational{BigInt}(Float64(value)) + +function _generalized_confounder_covariance_oracle( + beta, + gamma, + delta, + confounder_variance, + exposure_noise_variance, +) + beta_value = _mirage_fraction(beta) + gamma_value = _mirage_fraction(gamma) + delta_value = _mirage_fraction(delta) + confounder_value = _mirage_fraction(confounder_variance) + noise_value = _mirage_fraction(exposure_noise_variance) + exposure_variance = delta_value^2 * confounder_value + noise_value + outcome_exposure_covariance = + beta_value * exposure_variance + gamma_value * delta_value * confounder_value + return Float64(outcome_exposure_covariance / exposure_variance) +end + +function _generalized_collider_normal_equation_oracle( + beta, + gamma, + delta, + outcome_noise_variance, + collider_noise_variance, +) + collider_loading = gamma * beta + delta + regressor_covariance = [ + 1.0 collider_loading + collider_loading collider_loading^2 + gamma^2 * outcome_noise_variance + collider_noise_variance + ] + outcome_covariance = [beta, collider_loading * beta + gamma * outcome_noise_variance] + return regressor_covariance \ outcome_covariance +end + +@testset "general-variance factor-mirage coefficients" begin + @testset "released standardized boundary" begin + @test confounder_undercontrolled_coefficient(1.0, 2.0, 3.0) == 1.6 + @test collider_overcontrolled_coefficients(1.0, 2.0, 3.0) == + ColliderCoefficients(-1.0, 0.4) + @test_throws MethodError confounder_undercontrolled_coefficient(1.0, 2.0, 3.0, 1.0) + @test_throws MethodError collider_overcontrolled_coefficients(1.0, 2.0, 3.0, 1.0) + end + + @testset "confounder covariance-ratio oracle" begin + cases = ( + (0.75, -1.25, 0.5, 2.0, 3.0), + (-0.4, 2.5, -0.75, 0.25, 4.0), + (1.5, 0.0, 8.0, 3.0, 0.5), + (0.0, -3.0, 0.0, 2.0, 7.0), + ) + for (beta, gamma, delta, confounder_variance, noise_variance) in cases + expected = _generalized_confounder_covariance_oracle( + beta, + gamma, + delta, + confounder_variance, + noise_variance, + ) + actual = generalized_confounder_undercontrolled_coefficient( + beta, + gamma, + delta; + confounder_variance = confounder_variance, + exposure_noise_variance = noise_variance, + ) + @test actual == expected + end + + for (beta, gamma, delta) in ((1.0, 3.0, 1.0), (-0.7, 1.2, -0.4), (2.5, 0.0, -8.0)) + @test generalized_confounder_undercontrolled_coefficient( + beta, + gamma, + delta; + confounder_variance = 1.0, + exposure_noise_variance = 1.0, + ) == confounder_undercontrolled_coefficient(beta, gamma, delta) + end + + baseline = generalized_confounder_undercontrolled_coefficient( + 0.8, + -1.4, + 0.6; + confounder_variance = 2.5, + exposure_noise_variance = 0.75, + ) + rescaled = generalized_confounder_undercontrolled_coefficient( + 0.8, + -1.4, + 0.6; + confounder_variance = 25.0, + exposure_noise_variance = 7.5, + ) + @test rescaled == baseline + end + + @testset "collider normal-equation oracle" begin + cases = ( + (0.8, 1.2, -0.4, 2.0, 3.0), + (-0.5, 0.75, 2.0, 0.25, 4.0), + (2.0, -1.0, -0.25, 3.5, 0.5), + (1.25, 0.0, 7.0, 5.0, 2.0), + ) + for (beta, gamma, delta, outcome_variance, collider_variance) in cases + expected = _generalized_collider_normal_equation_oracle( + beta, + gamma, + delta, + outcome_variance, + collider_variance, + ) + actual = generalized_collider_overcontrolled_coefficients( + beta, + gamma, + delta; + outcome_noise_variance = outcome_variance, + collider_noise_variance = collider_variance, + ) + @test actual.beta_hat ≈ expected[1] rtol = 2.0e-14 atol = 2.0e-14 + @test actual.theta_hat ≈ expected[2] rtol = 2.0e-14 atol = 2.0e-14 + end + + for (beta, gamma, delta) in ((0.5, 1.0, 0.8), (1.0, -2.0, 0.25), (-0.7, 0.4, -1.2)) + @test generalized_collider_overcontrolled_coefficients( + beta, + gamma, + delta; + outcome_noise_variance = 1.0, + collider_noise_variance = 1.0, + ) == collider_overcontrolled_coefficients(beta, gamma, delta) + end + + baseline = generalized_collider_overcontrolled_coefficients( + 0.8, + 1.2, + -0.4; + outcome_noise_variance = 2.0, + collider_noise_variance = 3.0, + ) + rescaled = generalized_collider_overcontrolled_coefficients( + 0.8, + 1.2, + -0.4; + outcome_noise_variance = 20.0, + collider_noise_variance = 30.0, + ) + @test rescaled == baseline + + no_collider_link = generalized_collider_overcontrolled_coefficients( + 0.7, + 0.0, + 4.0; + outcome_noise_variance = 8.0, + collider_noise_variance = 0.5, + ) + @test no_collider_link == ColliderCoefficients(0.7, 0.0) + end + + @testset "deterministic randomized normal equations" begin + rng = MersenneTwister(104729) + for _ = 1:100 + beta, gamma, delta = randn(rng, 3) + outcome_variance = exp(randn(rng)) + collider_variance = exp(randn(rng)) + expected = _generalized_collider_normal_equation_oracle( + beta, + gamma, + delta, + outcome_variance, + collider_variance, + ) + actual = generalized_collider_overcontrolled_coefficients( + beta, + gamma, + delta; + outcome_noise_variance = outcome_variance, + collider_noise_variance = collider_variance, + ) + @test [actual.beta_hat, actual.theta_hat] ≈ expected rtol = 5.0e-12 atol = + 5.0e-12 + end + end + + @testset "validation" begin + @test_throws UndefKeywordError generalized_confounder_undercontrolled_coefficient( + 1.0, + 2.0, + 3.0, + ) + @test_throws UndefKeywordError generalized_collider_overcontrolled_coefficients( + 1.0, + 2.0, + 3.0, + ) + @test_throws MethodError generalized_confounder_undercontrolled_coefficient( + 1.0, + 2.0, + 3.0, + 1.0, + 1.0, + ) + + for variance in (0.0, -1.0) + @test_throws ArgumentError generalized_confounder_undercontrolled_coefficient( + 0.5, + 1.0, + -0.25; + confounder_variance = variance, + exposure_noise_variance = 1.0, + ) + @test_throws ArgumentError generalized_collider_overcontrolled_coefficients( + 0.5, + 1.0, + -0.25; + outcome_noise_variance = variance, + collider_noise_variance = 1.0, + ) + end + + for invalid in (true, NaN, Inf, 1.0 + 2.0im, "1") + @test_throws ArgumentError generalized_confounder_undercontrolled_coefficient( + invalid, + 1.0, + 0.5; + confounder_variance = 1.0, + exposure_noise_variance = 1.0, + ) + @test_throws ArgumentError generalized_collider_overcontrolled_coefficients( + 0.5, + 1.0, + 0.5; + outcome_noise_variance = invalid, + collider_noise_variance = 1.0, + ) + end + end +end diff --git a/test/causal_factor_analysis/test_graph_roles.jl b/test/causal_factor_analysis/test_graph_roles.jl new file mode 100644 index 0000000..605c65f --- /dev/null +++ b/test/causal_factor_analysis/test_graph_roles.jl @@ -0,0 +1,174 @@ +function graph_role_dag(edges = (); nodes = ("N", "X", "Y"), observed = nodes) + return CausalDAG(Tuple(nodes), Tuple(edges), Tuple(observed)) +end + +function graph_role_transitive_closure(nodes, edges) + index = Dict(node => position for (position, node) in enumerate(nodes)) + reachable = falses(length(nodes), length(nodes)) + for (source, target) in edges + reachable[index[source], index[target]] = true + end + for middle in eachindex(nodes), left in eachindex(nodes), right in eachindex(nodes) + reachable[left, right] |= reachable[left, middle] & reachable[middle, right] + end + return reachable, index +end + +function graph_role_four_node_dags() + nodes = ("A", "B", "C", "D") + pairs = (("A", "B"), ("A", "C"), ("A", "D"), ("B", "C"), ("B", "D"), ("C", "D")) + graphs = Tuple[] + for encoding = 0:(3^length(pairs)-1) + states = digits(encoding; base = 3, pad = length(pairs)) + edges = Tuple{String,String}[] + for (pair, state) in zip(pairs, states) + state == 1 && push!(edges, pair) + state == 2 && push!(edges, reverse(pair)) + end + try + CausalDAG(nodes, Tuple(edges), nodes) + push!(graphs, Tuple(edges)) + catch error + error isa ArgumentError || rethrow() + end + end + return nodes, graphs +end + +@testset "accepted-DAG factor control roles" begin + graph = CausalDAG( + ("A", "D", "L_A", "L_D", "O", "T"), + (("A", "T"), ("D", "L_D"), ("L_A", "A"), ("T", "D")), + ("A", "D", "O", "T"), + ) + roles = factor_control_roles(graph, "T") + @test roles.target_factor == "T" + @test roles.ancestor_factors == ("A",) + @test roles.descendant_factors == ("D",) + @test roles.other_factors == ("O",) + @test roles.unobserved_ancestors == ("L_A",) + @test roles.unobserved_descendants == ("L_D",) + + reversed_graph = CausalDAG( + reverse(graph.nodes), + reverse(graph.directed_edges), + reverse(graph.observed_nodes), + ) + @test factor_control_roles(reversed_graph, "T").ancestor_factors == + roles.ancestor_factors + @test factor_control_roles(reversed_graph, "T").descendant_factors == + roles.descendant_factors + + @test_throws ArgumentError factor_control_roles(nothing, "T") + @test_throws ArgumentError factor_control_roles(graph, :T) + @test_throws ArgumentError factor_control_roles(graph, "missing") + @test_throws ArgumentError factor_control_roles(graph, "L_A") +end + +@testset "exhaustive four-node factor-role oracle" begin + nodes, graphs = graph_role_four_node_dags() + queries = 0 + for edges in graphs + dag = CausalDAG(nodes, edges, nodes) + reachable, index = graph_role_transitive_closure(nodes, edges) + for target in nodes + expected_ancestors = Tuple( + sort([ + node for node in nodes if + node != target && reachable[index[node], index[target]] + ]), + ) + expected_descendants = Tuple( + sort([ + node for node in nodes if + node != target && reachable[index[target], index[node]] + ]), + ) + expected_other = Tuple( + sort([ + node for node in nodes if node != target && + !(node in expected_ancestors) && + !(node in expected_descendants) + ],), + ) + actual = factor_control_roles(dag, target) + @test actual.ancestor_factors == expected_ancestors + @test actual.descendant_factors == expected_descendants + @test actual.other_factors == expected_other + @test isempty(actual.unobserved_ancestors) + @test isempty(actual.unobserved_descendants) + queries += 1 + end + end + @test length(graphs) == 543 + @test queries == 2_172 +end + +@testset "one-hop treatment/outcome roles" begin + expected = Dict( + (true, false, false, false) => :cause_of_treatment, + (false, true, false, false) => :consequence_of_treatment, + (false, false, true, false) => :cause_of_outcome, + (false, false, false, true) => :consequence_of_outcome, + (true, false, true, false) => :confounder, + (false, true, false, true) => :collider, + (false, true, true, false) => :mediator, + (false, false, false, false) => :independent, + ) + relationship_edges = ((), (("N", "X"),), (("X", "N"),)) + outcome_edges = ((), (("N", "Y"),), (("Y", "N"),)) + observed_signatures = 0 + for treatment_edges in relationship_edges, result_edges in outcome_edges + edges = (treatment_edges..., result_edges...) + signature = ( + ("N", "X") in edges, + ("X", "N") in edges, + ("N", "Y") in edges, + ("Y", "N") in edges, + ) + dag = graph_role_dag(edges) + if haskey(expected, signature) + evidence = classify_treatment_outcome_role(dag, "X", "Y", "N") + @test evidence.node == "N" + @test evidence.treatment == "X" + @test evidence.outcome == "Y" + @test evidence.role == TreatmentOutcomeRole(expected[signature]) + @test ( + evidence.node_to_treatment, + evidence.treatment_to_node, + evidence.node_to_outcome, + evidence.outcome_to_node, + ) == signature + else + @test signature == (true, false, false, true) + @test_throws ArgumentError classify_treatment_outcome_role(dag, "X", "Y", "N") + end + observed_signatures += 1 + end + @test observed_signatures == 9 + + indirect = CausalDAG( + ("A", "N", "X", "Y"), + (("N", "A"), ("A", "X"), ("N", "Y")), + ("A", "N", "X", "Y"), + ) + @test classify_treatment_outcome_role(indirect, "X", "Y", "N").role == + TreatmentOutcomeRole(:cause_of_outcome) + + @test string(TreatmentOutcomeRole(:confounder)) == "confounder" + @test TreatmentOutcomeRole("collider") == TreatmentOutcomeRole(:collider) + @test_throws ArgumentError TreatmentOutcomeRole(:unsupported) + @test_throws ArgumentError classify_treatment_outcome_role( + graph_role_dag(), + "X", + "X", + "N", + ) + @test_throws ArgumentError classify_treatment_outcome_role( + graph_role_dag(), + "X", + "Y", + :N, + ) + @test_throws ArgumentError classify_treatment_outcome_role(nothing, "X", "Y", "N") +end diff --git a/test/causal_factor_analysis/test_structural_models.jl b/test/causal_factor_analysis/test_structural_models.jl new file mode 100644 index 0000000..e79e114 --- /dev/null +++ b/test/causal_factor_analysis/test_structural_models.jl @@ -0,0 +1,203 @@ +function structural_chain() + return CausalDAG(("X", "Y", "Z"), (("X", "Y"), ("Y", "Z")), ("X", "Y", "Z")) +end + +@testset "deterministic structural causal model" begin + graph = structural_chain() + exogenous = + Dict("X" => [1.0, 2.0, 3.0], "Y" => [0.5, -0.5, 1.0], "Z" => [2.0, 2.0, 2.0]) + mechanisms = Dict{String,Any}( + "X" => ((parents, noise) -> noise), + "Y" => ((parents, noise) -> 2.0 .* parents[:, 1] .+ noise), + "Z" => ((parents, noise) -> parents[:, 1] .^ 2 .+ noise), + ) + result = evaluate_structural_causal_model(graph, mechanisms, exogenous) + x = exogenous["X"] + y = 2.0 .* x .+ exogenous["Y"] + z = y .^ 2 .+ exogenous["Z"] + @test result.node_order == ("X", "Y", "Z") + @test result.values ≈ hcat(x, y, z) atol = 1.0e-14 + + named_result = evaluate_structural_causal_model( + graph, + ( + X = (parents, noise) -> noise, + Y = (parents, noise) -> 2.0 .* parents[:, 1] .+ noise, + Z = (parents, noise) -> parents[:, 1] .^ 2 .+ noise, + ), + (X = exogenous["X"], Y = exogenous["Y"], Z = exogenous["Z"]), + ) + @test named_result.node_order == result.node_order + @test named_result.values == result.values +end + +@testset "lexical topological and parent order" begin + graph = CausalDAG(("D", "C", "B", "A"), (("B", "D"), ("A", "D")), ("D", "C", "B", "A")) + parent_inputs = Matrix{Float64}[] + d_mechanism = function (parents, noise) + push!(parent_inputs, copy(parents)) + return parents[:, 1] .+ 10.0 .* parents[:, 2] .+ noise + end + result = evaluate_structural_causal_model( + graph, + Dict{String,Any}( + "D" => d_mechanism, + "C" => ((parents, noise) -> noise), + "B" => ((parents, noise) -> noise), + "A" => ((parents, noise) -> noise), + ), + Dict( + "D" => [0.0, 0.0], + "C" => [30.0, 31.0], + "B" => [20.0, 21.0], + "A" => [1.0, 2.0], + ), + ) + @test result.node_order == ("A", "B", "C", "D") + @test only(parent_inputs) == [1.0 20.0; 2.0 21.0] + @test result.values[:, 4] == [201.0, 212.0] +end + +@testset "independent linear-system oracle" begin + nodes = ("A", "B", "C", "D") + graph = CausalDAG( + reverse(nodes), + (("A", "C"), ("B", "C"), ("C", "D"), ("A", "D")), + reverse(nodes), + ) + coefficients = [ + 0.0 0.0 0.0 0.0 + 0.0 0.0 0.0 0.0 + 1.5 -0.25 0.0 0.0 + 0.5 0.0 2.0 0.0 + ] + noises = [ + 1.0 2.0 0.5 -1.0 + -2.0 1.0 1.5 0.25 + 0.0 -1.0 2.0 3.0 + ] + mechanisms = Dict{String,Any}( + "A" => ((parents, noise) -> noise), + "B" => ((parents, noise) -> noise), + "C" => ((parents, noise) -> + 1.5 .* parents[:, 1] .- 0.25 .* parents[:, 2] .+ noise), + "D" => + ((parents, noise) -> 0.5 .* parents[:, 1] .+ 2.0 .* parents[:, 2] .+ noise), + ) + exogenous = Dict(node => noises[:, column] for (column, node) in enumerate(nodes)) + result = evaluate_structural_causal_model(graph, mechanisms, exogenous) + oracle = transpose((I - coefficients) \ transpose(noises)) + @test result.node_order == nodes + @test result.values ≈ oracle atol = 1.0e-14 +end + +@testset "SCM input and output snapshots" begin + source = [1.0, 2.0] + mechanism_output = copy(source) + received = Tuple{Matrix{Float64},Vector{Float64}}[] + root = function (parents, noise) + push!(received, (parents, noise)) + noise .+= 5.0 + return mechanism_output + end + graph = CausalDAG(("X",), (), ("X",)) + result = evaluate_structural_causal_model(graph, Dict("X" => root), Dict("X" => source)) + source .= 10.0 + mechanism_output .= 20.0 + @test size(only(received)[1]) == (2, 0) + @test only(received)[2] == [6.0, 7.0] + @test result.values[:, 1] == [1.0, 2.0] + + result.values[1, 1] = -1.0 + @test source == [10.0, 10.0] + @test mechanism_output == [20.0, 20.0] +end + +@testset "SCM validation" begin + graph = CausalDAG(("X",), (), ("X",)) + root = (parents, noise) -> noise + @test_throws ArgumentError evaluate_structural_causal_model(nothing, Dict(), Dict()) + @test_throws ArgumentError evaluate_structural_causal_model( + graph, + (), + Dict("X" => [1.0]), + ) + @test_throws ArgumentError evaluate_structural_causal_model( + graph, + Dict("X" => root), + (), + ) + @test_throws ArgumentError evaluate_structural_causal_model( + graph, + Dict(), + Dict("X" => [1.0]), + ) + @test_throws ArgumentError evaluate_structural_causal_model( + graph, + Dict("X" => root), + Dict(), + ) + @test_throws ArgumentError evaluate_structural_causal_model( + graph, + Dict("X" => root, "Y" => root), + Dict("X" => [1.0]), + ) + @test_throws ArgumentError evaluate_structural_causal_model( + graph, + Dict{Any,Any}("X" => root, :X => root), + Dict("X" => [1.0]), + ) + @test_throws ArgumentError evaluate_structural_causal_model( + graph, + Dict("X" => 1), + Dict("X" => [1.0]), + ) + @test_throws ArgumentError evaluate_structural_causal_model( + graph, + Dict("X" => root), + Dict("X" => Float64[]), + ) + @test_throws ArgumentError evaluate_structural_causal_model( + graph, + Dict("X" => root), + Dict("X" => reshape([1.0], 1, 1)), + ) + @test_throws ArgumentError evaluate_structural_causal_model( + graph, + Dict("X" => root), + Dict("X" => [true]), + ) + @test_throws ArgumentError evaluate_structural_causal_model( + graph, + Dict("X" => root), + Dict("X" => [Inf]), + ) + + for invalid_mechanism in ( + (parents, noise) -> 1.0, + (parents, noise) -> reshape([1.0], 1, 1), + (parents, noise) -> [1.0, 2.0], + (parents, noise) -> [NaN], + (parents, noise) -> ComplexF64[1.0+1.0im], + (parents, noise) -> [true], + ) + @test_throws ArgumentError evaluate_structural_causal_model( + graph, + Dict("X" => invalid_mechanism), + Dict("X" => [0.0]), + ) + end + + two_node_graph = CausalDAG(("X", "Y"), (("X", "Y"),), ("X", "Y")) + called = String[] + mechanisms = Dict{String,Any}( + "X" => ((parents, noise) -> (push!(called, "X"); noise)), + "Y" => ((parents, noise) -> (push!(called, "Y"); noise)), + ) + @test_throws ArgumentError evaluate_structural_causal_model( + two_node_graph, + mechanisms, + Dict("X" => [1.0], "Y" => [1.0, 2.0]), + ) + @test isempty(called) +end diff --git a/test/preserved_runtests.jl b/test/preserved_runtests.jl index 5b7406a..bd2a532 100644 --- a/test/preserved_runtests.jl +++ b/test/preserved_runtests.jl @@ -366,7 +366,7 @@ end # NERCOME is a seeded, sample-splitting estimator: the split permutations use # Julia's RNG, not NumPy's PCG64, so (like the other path-level modules) the # estimate is reproducible under a given seed but not bit-identical to Python. - # We assert the documented properties and the admitted mechanism (Appraisal 24: + # We assert the documented properties and the documented mechanism (independent validation: # lower covariance error than MP clipping on a no-gap / non-stationary spectrum). D = RiskLabAI.Data @@ -380,7 +380,7 @@ end @test est_a == est_b # reproducible under a fixed seed @test_throws ArgumentError D.nercome_denoised_covariance(randn(3, 4)) # needs >= 4 obs - # Mechanism (verbatim regime, Appraisal 24): on a slowly-decaying spectrum with + # Mechanism (verbatim regime, independent validation): on a slowly-decaying spectrum with # no clean gap and T ~ p, NERCOME recovers the covariance more accurately than MP # eigenvalue clipping (and than the raw sample covariance). Fully deterministic # under the fixed RNG seeds below. @@ -404,7 +404,7 @@ end push!(clipping_error, relative_frobenius(clipping, sigma)) push!(raw_error, relative_frobenius(sample_cov, sigma)) end - @test mean(nercome_error) < mean(clipping_error) # the admitted edge + @test mean(nercome_error) < mean(clipping_error) # the documented effect @test mean(nercome_error) < mean(raw_error) # regularizes the raw sample end diff --git a/test/runtests.jl b/test/runtests.jl index fcf85b1..504d1ae 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -3,8 +3,7 @@ include("preserved_runtests.jl") using Test using LinearAlgebra -include(joinpath(@__DIR__, "..", "src", "CausalFactorAnalysis", "CausalFactorAnalysis.jl")) -using .CausalFactorAnalysis +using RiskLabAI.CausalFactorAnalysis const EXPECTED_CAUSAL_FACTOR_EXPORTS = Set(( :BackdoorAdjustmentEvidence, @@ -64,6 +63,36 @@ const EXPECTED_CAUSAL_FACTOR_EXPORTS = Set(( :randomized_mean_difference, :treatment_effect_decomposition, :validate_causal_factor_protocol, + :AllocationMisspecificationDiagnostics, + :FDRComparisonEvidence, + :FDRNonIdentificationWitness, + :FactorControlRoles, + :GaussianSearchAdjustedFDR, + :GaussianTrialMixture, + :MaxSelectionFamilyErrors, + :SelectionLevelProbabilityEvidence, + :StructuralCausalModelResult, + :TreatmentOutcomeRole, + :TreatmentOutcomeRoleEvidence, + :allocation_misspecification_diagnostics, + :classify_treatment_outcome_role, + :compare_single_and_family_fdr, + :conditional_upper_tail_probability, + :evaluate_structural_causal_model, + :factor_control_roles, + :family_level_false_discovery_rate, + :fdr_nonidentification_witness, + :gaussian_max_selection_cdf, + :gaussian_max_selection_log_density, + :gaussian_max_selection_log_likelihood, + :gaussian_search_adjusted_false_discovery_rate, + :gaussian_trial_mixture_cdf, + :generalized_collider_overcontrolled_coefficients, + :generalized_confounder_undercontrolled_coefficient, + :max_selection_family_errors, + :max_selection_null_probability, + :maximum_mixture_cdf, + :single_trial_false_discovery_rate, )) @testset "CausalFactorAnalysis" begin @@ -75,4 +104,10 @@ const EXPECTED_CAUSAL_FACTOR_EXPORTS = Set(( include("causal_factor_analysis/test_optimizer.jl") include("causal_factor_analysis/test_graph_identification.jl") include("causal_factor_analysis/test_protocol.jl") + include("causal_factor_analysis/test_generalized_factor_mirage.jl") + include("causal_factor_analysis/test_allocation_diagnostics.jl") + include("causal_factor_analysis/test_graph_roles.jl") + include("causal_factor_analysis/test_structural_models.jl") + include("causal_factor_analysis/test_false_discovery_rates.jl") + include("causal_factor_analysis/test_additive_numeric_parity.jl") end