Programming for data science

Julia for Data Scientists: Turn Predictions into Constrained Decisions

Build and audit a reproducible JuMP mixed-integer optimisation that turns fixed model predictions into a synthetic, constraint-aware monitoring plan.

2026 edition · Reproduced 16 August 2026

On this page

Reproduction contract

What this guide lets you reproduce

The workflow, data snapshot and checks below are versioned together. Use the stated commands and compare your results with the tested outputs in the repository.

Before you begin

  • Ability to read Julia-like array operations and a chronological prediction table
  • Familiarity with train, validation and test roles and the danger of using realised outcomes in a decision rule
  • Completion of the Python guide or an equivalent predictions.csv handover

You will be able to

  • Create an isolated, manifest-locked Julia project for a reproducible decision workflow
  • Separate model predictions, synthetic scenario assumptions, decision variables and realised outcomes
  • Formulate a binary mixed-integer monitoring plan with total, daily, monthly, hourly and weekend coverage constraints in JuMP
  • Check solver termination and primal status before reading a candidate solution
  • Recalculate every constraint and objective term outside the JuMP model before publishing deterministic JSON
  • Use scenario and forecast sensitivity analysis without presenting a teaching optimum as an operational recommendation

Tested environment

Pinned dataset

UCI Bike Sharing — hour.csv, LF-normalised repository copy

Historical 2011–2012 Washington, DC recorded rentals; no station-level demand, capacity, bicycle or dock availability, rebalancing operations, monitoring costs or basis for present-day, causal or operational recommendations. The optimisation consumes fixed predictions, not the raw dataset.

Runnable companion

Review and disclosure

AI assistance helped draft this first-party Julia and JuMP teaching extension. All limits, budgets and coverage requirements in scenario.json are labelled synthetic teaching assumptions, not observed facts. Clean-environment reproduction was verified in the linked GitHub Actions run on 16 August 2026, followed by human editorial review; no causal, deployment, solver-performance or operational-optimality claim is made.

Prediction is not the end of a data-science workflow. A team may still need to choose a finite set of reviews, inspections, interventions or experiments while respecting budget and coverage rules. That is a decision problem, and it requires a different contract from model training. This applied Programming for Data Scientists extension uses Julia and JuMP to turn already fixed predictions into a constrained historical monitoring plan.

The exercise deliberately does not retrain the Python model inside Julia. Its exact optimisation input is a committed, versioned decision_candidates.csv snapshot derived from the 2,168 rows in the October–December 2012 test period. Each CI run still rebuilds the predictions.csv handover from Python for Data Scientists, then checks its identities and model scores against that reviewed snapshot within a narrow declared tolerance. A separate scenario.json supplies a synthetic review budget and synthetic coverage requirements. Binary decision variables determine which historical hours would have been selected under that declared scenario.

This is a JuMP tutorial for data scientists, not a claim that recorded bicycle rentals equal unconstrained demand or that monitoring a high-prediction hour changes an outcome. The useful result is a reviewable pattern: prediction evidence enters through one boundary, invented decision assumptions enter through another, and an independent audit checks the optimiser’s output before it becomes a downstream artefact.

Start with the decision boundary

Four concepts that are often collapsed into one notebook have different evidential status here:

LayerSourceRole in this guideMust not be interpreted as
Decision evidenceInteger scores in committed decision_candidates.csvReviewed, immutable score used to prioritise candidate monitoring hoursKnown future demand or causal benefit
Integration evidenceRegenerated model_prediction values in predictions.csvIdentity and tolerance check against the committed cohortAuthority to silently rewrite the optimisation problem
Scenario assumptionsscenario.jsonSynthetic limits and coverage rules for the teaching problemObserved capacity, cost, policy or service requirement
DecisionBinary selection written by JuliaThe plan that is optimal for the stated score and constraintsA universally optimal operational plan
Realised outcomeactual in the historical handoverExcluded from model construction and selectionInformation available when the plan would be made

That last separation is essential. predictions.csv contains realised outcomes because the core workflow uses them for holdout evaluation. The Julia loader validates the complete regenerated handover, filters to split == "test", aligns those rows with the committed cohort and checks that every fresh model score remains within 0.00051 rentals of its reviewed score equivalent. JuMP receives only the committed candidate records. It cannot use actual, baseline_prediction or the regenerated floating-point forecast in the objective, constraints or tie-breaking. Tests change those fields while keeping the integration contract valid and require the selected plan to remain unchanged.

The model therefore answers a narrow counterfactual engineering question:

Given these fixed historical predictions and these explicitly synthetic coverage rules, which 184 hourly records maximise forecast-weighted monitoring coverage?

It does not answer how many bicycles should be moved, what service capacity was available, whether extra monitoring would improve service, or what should happen in a current transport system.

Install Julia as a project runtime

The reproducible unit is the project, not a global Julia environment. Install the official Juliaup version manager, add the exact stable release used by this package and confirm the runtime:

juliaup add 1.12.7
julia +1.12.7 --version

Then instantiate the committed project from the extension directory:

cd examples/programming-for-data-science/julia
julia +1.12.7 --project=. --startup-file=no --history-file=no \
  -e 'using Pkg; Pkg.instantiate(); Pkg.status()'

Project.toml declares direct dependencies and exact compatibility bounds. Manifest.toml records the complete resolved graph, including package source identities and solver artefacts. Pkg.instantiate() recreates that graph instead of updating it. Do not begin a reproduction with Pkg.update(): an update is a visible maintenance task that needs a new manifest, tests and review.

The --startup-file=no flag prevents a user’s personal Julia configuration from changing the run, while --history-file=no avoids writing REPL history in automation. An editor, notebook or Julia REPL is useful for exploration, but the checked command-line entry point remains the review contract.

This guide pins Julia 1.12.7, JuMP 1.31.1, HiGHS.jl 1.24.1, the native HiGHS 1.15.1 engine resolved through the manifest and JSON3.jl 1.14.3. The package uses the stable Julia line rather than the Julia 1.13 release candidate available during development. A prerelease may be valuable for compatibility testing, but it should not silently become the published baseline.

Validate the handover and freeze the decision input

The integration input is the same machine-readable handover audited by the downstream R, JavaScript/TypeScript and C++ extensions:

instant,observed_at,observation_date,split,actual,baseline_prediction,model_prediction

Before a JuMP model exists, the loader requires:

  • the exact ordered header and exactly 4,376 validation-plus-test rows;
  • 2,168 test rows with unique identifiers and strictly increasing timestamps;
  • the declared October–December 2012 test boundary;
  • agreement between observed_at and observation_date;
  • finite numeric fields, non-negative observations and non-negative test-period model scores; and
  • no duplicate hour or silent row omission.

The source retains one typed PredictionRow so it can validate the complete cross-language handover. The actual optimisation input is a separate committed file with this exact five-column header:

instant,observed_at,observation_date,split,score_milli_rentals

It has 2,168 ordered rows, excludes outcomes and baselines, and is pinned by its raw-file SHA-256. Julia parses it into the exact type consumed by the optimisation:

struct DecisionCandidate
    instant::Int
    observed_at::DateTime
    observation_date::Date
    split::String
    score_milli_rentals::Int
end

Score derivation happened once when the reviewed decision snapshot was created. The runnable solve_plan function uses observation_date and observed_at to form coverage groups and reads only score_milli_rentals for the objective. It cannot read actual, baseline_prediction or the fresh raw forecast because those fields do not exist on DecisionCandidate. Keeping the complete generated fields in a separately validated PredictionRow makes the integration check explicit, while narrowing the solver type enforces the prediction-time boundary structurally.

Strong typing is useful, but it is not enough. A Float64 may still be non-finite, a timestamp may fall outside the test period and a structurally valid column may be invalid at the prediction moment. Domain assertions remain explicit.

The package does not make newly generated prediction bytes the cross-machine decision identity. The same version-locked pipeline produced sub-milli differences across numerical backends, including a value that crossed a half-milli rounding boundary. Regenerating the objective from each refit would therefore make a supposedly fixed decision problem platform-dependent. Instead, the scenario pins the SHA-256 of the committed ordered candidate snapshot. Its construction provenance records the exact raw prediction SHA from which it was created, while each workflow run records its own raw prediction SHA separately as informational provenance.

The fresh handover remains a strong integration gate. All candidate identities, timestamps, dates, splits and order must match, and each regenerated test forecast must stay within 0.00051 rentals of the committed score divided by 1,000. That tolerance is deliberately just beyond a half-milli boundary. A fresh value may derive an adjacent milli score and still pass; it does not replace the reviewed coefficient. Drift beyond the tolerance fails before solving. Tests also require candidate-file tampering, reordered identities and changed timestamps to fail while preserving earlier outputs.

These digest contracts do not replace semantic validation. Invalid UTF-8, quoting, CRLF, a missing final newline, a changed schema, reordered or duplicate rows, an unknown split, a non-finite number or a temporal-boundary change still fails closed. The candidate digest identifies the reviewed decision snapshot; it does not prove that its modelling assumptions are appropriate.

Keep invented requirements in scenario.json

All decision parameters live in one separately reviewed JSON document. The scenario labels itself as synthetic and states that its values are neither measured nor recommended. This valid JSON projection uses the runnable file’s exact names and nesting; it omits the input provenance block and, for readability, shows the common hourly minimum as a prose note immediately below:

{
  "schema_version": 4,
  "scenario_id": "synthetic-historical-monitoring-v4",
  "synthetic": true,
  "description": "Invented teaching constraints for selecting historical holdout hours to review; these values do not describe a real bike-sharing operation.",
  "decision": {
    "total_slots": 184,
    "maximum_slots_per_date": 3,
    "minimum_weekend_slots": 30
  },
  "coverage": {
    "minimum_slots_by_month": {
      "2012-10": 55,
      "2012-11": 55,
      "2012-12": 55
    }
  },
  "objective": {
    "prediction_field": "model_prediction",
    "score_scale": 1000,
    "score_rounding": "nearest_ties_to_even_after_six_decimal_canonicalization",
    "sense": "maximize",
    "tie_break": "minimize_sum_of_chronological_row_ranks"
  }
}

The complete checked-in coverage.minimum_slots_by_hour object has exactly the string keys "0" through "23", each mapped to 4. Use the checked-in scenario.json, not this shortened projection, when running the package. Its omitted input block pins the committed candidate-file digest, the source prediction SHA used to construct that snapshot, the 0.00051-rental integration tolerance, the six-decimal construction rule, split, row counts, distinct-date count and temporal endpoints. The objective block also pins nearest_ties_to_even_after_six_decimal_canonicalization rather than leaving the snapshot’s score derivation implicit.

The loader requires the exact top-level and nested key sets, synthetic: true, a non-empty description, the declared prediction field and objective sense, integer decision limits, all three expected months and all 24 hours. It also rejects values that violate direct bounds such as a monthly or weekend minimum exceeding the total slot count. Joint interactions still require a solver feasibility check.

These values exist to create a non-trivial, reproducible mixed-integer program:

  • exactly 184 of 2,168 test hours must be selected;
  • no calendar day may contribute more than three slots;
  • each of October, November and December must contribute at least 55 slots;
  • every hour of day, from 00 through 23, must appear at least four times; and
  • at least 30 selected slots must fall on Saturday or Sunday.

None of those numbers came from the UCI dataset. Keeping them outside source code makes scenario sensitivity possible and prevents a reader from mistaking a hard-coded teaching parameter for observed operational evidence.

After validation, the parser converts the nested JSON into a typed Scenario whose decision and coverage values are flat fields such as total_slots, maximum_slots_per_date and minimum_slots_by_hour. The JuMP excerpts below use that internal type, while the JSON excerpt above shows the external contract.

Translate the question into a mixed-integer model

Let I be the committed test candidates. For each row i, let p_i be the six-decimal model prediction used when the snapshot was constructed, s_i its committed integer milli-rental score, r_i its one-based chronological rank and x_i a binary decision equal to one when that hour is selected. The snapshot score was computed from exact six-decimal integer units as round-to-nearest-even(1000 p_i), without multiplying a binary floating-point approximation. Exact halves therefore have a declared policy: 0.501500 becomes 502, while reviewed instant 15682 maps 641.734500 to 641734. Ordinary reproduction consumes s_i directly rather than deriving it again. Let I_d, I_m, I_h and I_weekend denote rows grouped by date, month, hour of day and weekend status.

The formulation has a deterministic lexicographic objective:

first       maximise sum(s_i * x_i for i in I)
then        among primary-optimal plans,
            minimise sum(r_i * x_i for i in I)

subject to  sum(x_i for i in I) = 184
            sum(x_i for i in I_d) <= 3                    for every date d
            sum(x_i for i in I_m) >= 55                   for every month m
            sum(x_i for i in I_h) >= 4                    for every hour h = 0,...,23
            sum(x_i for i in I_weekend) >= 30
            x_i in {0, 1}                                 for every row i

The primary objective is a forecast-weighted monitoring score. Scaling the canonical six-decimal integer units and applying round-to-nearest, ties-to-even gives the MIP an explicit integer comparison policy without a binary floating-point tie ambiguity. It is not expected rentals “saved,” revenue, welfare or causal uplift. Calling it a score preserves what the mathematics actually contains: larger model predictions receive more weight, while the scenario forces temporal coverage that an unconstrained top-184 ranking would not guarantee.

The secondary objective does not trade away any primary score. After HiGHS proves the primary optimum, the implementation fixes that score exactly and minimises the sum of chronological ranks. This selects one stable representative when multiple plans have the same integerised score. The tie-break uses neither actual nor baseline_prediction.

This is a binary mixed-integer linear program. All expressions are linear, but integrality matters because a monitoring slot cannot be selected fractionally. HiGHS supports the required MIP class through its JuMP interface.

Express sets before JuMP macros

Build the index groups in ordinary Julia first. A model is easier to review when calendar semantics are resolved before algebraic macros run:

indices = eachindex(rows)
by_date = Dict(date => findall(i -> rows[i].observation_date == date, indices)
               for date in unique(row.observation_date for row in rows))
by_month = Dict(month => findall(i -> month_key(rows[i]) == month, indices)
                for month in ("2012-10", "2012-11", "2012-12"))
by_hour = Dict(hour => findall(i -> Dates.hour(rows[i].observed_at) == hour, indices)
               for hour in 0:23)
weekend = findall(i -> dayofweek(rows[i].observation_date) in (6, 7), indices)

The runnable implementation derives each group directly from the already validated rows with findall, then the post-solve audit independently rebuilds the corresponding date, month, hour and weekend counts. For a larger model, precompute and test these index maps so repeated scans do not dominate model construction. A solver will faithfully optimise the wrong groups if Sunday is mislabelled or a timestamp is parsed under the wrong calendar convention.

Then declare the model, binary variables, objective and constraints:

using JuMP
import HiGHS

model = Model(HiGHS.Optimizer)
set_silent(model)
set_attribute(model, "threads", 1)
set_attribute(model, "random_seed", 0)
set_attribute(model, "parallel", "off")

@variable(model, selected[indices], Bin)
score = [row.score_milli_rentals for row in rows]
@expression(model, primary_score,
    sum(score[i] * selected[i] for i in indices))
@objective(model, Max, primary_score)

@constraint(model, sum(selected) == scenario.total_slots)
@constraint(model, [ids in values(by_date)],
    sum(selected[i] for i in ids) <= scenario.maximum_slots_per_date)
for (month, required) in scenario.minimum_slots_by_month
    @constraint(model, sum(selected[i] for i in by_month[month]) >= required)
end
for (hour, required) in scenario.minimum_slots_by_hour
    @constraint(model, sum(selected[i] for i in by_hour[hour]) >= required)
end
@constraint(model,
    sum(selected[i] for i in weekend) >= scenario.minimum_weekend_slots)

After the first optimal solve, the implementation adds primary_score == primary_optimum, replaces the objective with Min, sum(i * selected[i] for i in indices) and solves again. Keeping the two phases explicit makes the “score first, chronology only for ties” policy easier to test than an arbitrary tiny floating-point coefficient.

JuMP’s macros make the formulation close to its mathematical statement, but macro readability does not validate the meaning of p_i or the legitimacy of the scenario. Those remain data-science review tasks.

Check the solve before reading values

Never call value merely because optimize! returned control. Solvers can stop because a model is infeasible, unbounded, interrupted or limited. This package requires a globally optimal, feasible result before extracting a plan:

function require_optimal_solution(model)
    termination_status(model) == JuMP.MOI.OPTIMAL || error(
        "Expected OPTIMAL, found $(termination_status(model))",
    )
    primal_status(model) == JuMP.MOI.FEASIBLE_POINT || error(
        "Expected FEASIBLE_POINT, found $(primal_status(model))",
    )
end

optimize!(model)
require_optimal_solution(model) # repeat after the tie-break solve too

chosen = findall(i -> value(selected[i]) > 0.5, indices)
length(chosen) == scenario.total_slots || error("Selected-slot count changed")

The 0.5 threshold interprets solver-returned binary values within numerical tolerance. It is not a relaxation of the model. The independent post-solve audit then treats chosen as plain row identifiers and no longer relies on JuMP expressions.

For this small teaching model, the package fixes HiGHS to one thread, disables parallel solving and sets its random seed to zero. It also excludes the chosen plan in a third solve and confirms that no different plan shares both the primary and chronological-rank scores. Selected rows are serialised in timestamp order. Re-running the same locked environment, committed candidate snapshot and scenario must produce byte-identical artefacts even when the compatible integration handover differs slightly. Solver determinism is an engineering contract for this example, not a guarantee across unpinned versions or arbitrary MIP models.

Audit the plan outside the model

A successful solver status is necessary but insufficient. A reporting bug can still omit a selected row, miscount a month or write a stale score. The audit therefore reconstructs the result from the chosen identifiers:

@assert length(chosen) == scenario.total_slots
@assert maximum(values(count_by_date(chosen))) <=
    scenario.maximum_slots_per_date

month_counts = count_by_month(chosen)
for (month, required) in scenario.minimum_slots_by_month
    @assert get(month_counts, month, 0) >= required
end

hour_counts = count_by_hour(chosen)
for (hour, required) in scenario.minimum_slots_by_hour
    @assert get(hour_counts, hour, 0) >= required
end
@assert count(i -> is_weekend(rows[i]), chosen) >=
    scenario.minimum_weekend_slots

recalculated_score = sum(score[i] for i in chosen)
@assert recalculated_score == primary_optimum
@assert sum(chosen) == tie_break_score

The actual implementation also checks that every output identifier existed exactly once in the committed snapshot, every reported score equals its committed candidate value and no unselected row appears. Negative tests mutate one scenario rule at a time, create infeasible combinations, change input order, duplicate timestamps and introduce non-finite predictions.

Especially important tests change actual, baseline_prediction and validation predictions, then perturb a test prediction across an adjacent milli boundary while keeping it inside the integration tolerance. The generated decision plan must remain the same. Those assertions turn “the committed cohort is authoritative and we do not use outcomes” from prose into executable leakage protection.

Publish evidence, not solver console text

The command produces a paired audit JSON and selected-hours CSV. The audit records:

  • schema and scenario labels, including the synthetic-input declaration;
  • the committed decision-candidate SHA-256, its construction-source SHA, integration tolerance, intermediate decimal precision and score-rounding rule, plus SHA-256 digests for scenario.json, Project.toml and Manifest.toml;
  • the exact Julia, JuMP, HiGHS.jl, native HiGHS and JSON3.jl versions;
  • solver termination and primal statuses;
  • row, date, month, hour and weekend counts;
  • the integer forecast-weighted objective score, its exact three-decimal score equivalent and chronological-rank score;
  • every selected instant and the SHA-256 digest of the companion CSV; and
  • independently recalculated counts and slack for every constraint.

The companion CSV stores each selected identifier, timestamp, month, hour, weekend flag, three-decimal score_equivalent_rentals and integer milli-rental score in chronological order. Both values are derived from the exact coefficient used by JuMP; the CSV does not copy a raw prediction. Neither artefact copies actual or baseline_prediction. That keeps the decision handover focused on information permitted at selection time. A later retrospective evaluation could join realised outcomes only after the plan has been frozen and with a separate analytical question.

Atomic output matters too. The command completes input validation, all three solves, the independent audit and both byte sequences before writing. Each output is written to a temporary file in its destination directory, flushed, closed and renamed into place. A failed validation or solve therefore cannot replace an earlier artefact with a partial document.

The two renames are not a filesystem transaction. A downstream consumer should treat the JSON and CSV as a pair and require the CSV digest to match selected_hours_csv_sha256 in the audit. The test workflow runs the reproduction twice and requires byte-identical JSON and CSV outputs rather than searching logs for a success phrase.

Reproduce the complete handover

First run the shared SQL, Python and R workflow from the repository root so that artifacts/predictions.csv exists under the reviewed core contract:

python3 -m venv examples/programming-for-data-science/.venv
examples/programming-for-data-science/.venv/bin/python -m pip install \
  -r examples/programming-for-data-science/requirements.txt
examples/programming-for-data-science/.venv/bin/python \
  examples/programming-for-data-science/test_all.py

Then instantiate the committed Julia environment without updating its lock file:

julia --startup-file=no --project=examples/programming-for-data-science/julia \
  -e 'using Pkg; Pkg.instantiate()'

Run the bounds-checked negative and golden test suite:

julia --startup-file=no --check-bounds=yes \
  --project=examples/programming-for-data-science/julia \
  examples/programming-for-data-science/julia/test/runtests.jl

Create the paired review artefacts from the regenerated integration handover, committed candidate snapshot and labelled synthetic scenario:

julia --startup-file=no \
  --project=examples/programming-for-data-science/julia \
  examples/programming-for-data-science/julia/run_allocation.jl \
  --predictions examples/programming-for-data-science/artifacts/predictions.csv \
  --candidates examples/programming-for-data-science/julia/data/decision_candidates.csv \
  --scenario examples/programming-for-data-science/julia/scenario.json \
  --audit-output examples/programming-for-data-science/artifacts/julia_allocation_audit.json \
  --selected-output examples/programming-for-data-science/artifacts/julia_selected_hours.csv

The runnable companion panel above and the package README preserve the exact one-line run and test commands for copying. The reviewed sequence is:

  1. materialise the committed manifest without updating it;
  2. run the Julia test suite, including negative and invariance cases;
  3. validate the regenerated handover against the committed candidate identities and tolerance;
  4. create the plan from decision_candidates.csv and scenario.json;
  5. create a second copy at distinct temporary output paths; and
  6. require byte identity before retaining the review artefact.

Generated plans belong under the shared ignored artifacts/ directory. They should not be committed as timeless evidence. A clean run establishes reproducibility for the pinned teaching inputs and environment; it does not validate the synthetic scenario as an organisational requirement.

Check the locked fixture expectations

The package test suite locks the following expectations for the committed decision snapshot, scenario, project and manifest. It changes realised outcomes and validation predictions, accepts both score-preserving drift and a fresh adjacent milli score within the handover tolerance, and requires the same audit and selected CSV. Candidate-snapshot changes or regenerated forecast drift beyond the tolerance fail closed. The clean-environment Julia run reproduced these checkpoints on 16 August 2026:

CheckpointLocked expectation
Selected slots184
October / November / December selections74 / 55 / 55
Weekend selections50
Minimum / maximum selections across the 24 hour-of-day groups4 / 39
Decision candidates SHA-256e0351cd7401f27af6f3b4698f5c91cb48d3e96831d649035a62296888cbae1cd
Snapshot-source predictions SHA-256bf71f9c30ad3b66e7caa269c4ba3a1af9cc7774d8c8d0ad01f207bcdae9e1ec4
Handover tolerance0.00051 rentals per test row
Snapshot construction precision6 decimal places
Primary integer score94,555,307 milli-rentals
Score-equivalent total94,555.307
Chronological-rank tie-break score182,140
Scenario v4 SHA-2560874e7bd7f8941b883c3cdd44f851581846750e40939d5f92e961e683f892dcd
Audit JSON SHA-25673893b60dfce5e8306ed34ae3e54bd97294d68f72928ef8cbe4d4c4638975f4e
Selected-hours CSV SHA-256c7987132167f502445bcdeb05cc95f51195c4bef6d9fd7500926545078b6a789

Matching these values verifies that the locked code solved and serialised this synthetic scenario deterministically. It does not establish that 184 slots, any coverage minimum or the forecast-weighted objective is suitable for a real monitoring programme.

Diagnose infeasibility rather than weakening checks

Coverage rules interact. A total budget can be individually compatible with each minimum and still be jointly infeasible once daily caps, month sizes and weekend membership overlap. When HiGHS reports infeasibility, do not increase a tolerance or delete a constraint until the scenario owner understands the conflict.

Begin with transparent necessary bounds:

  • 3 × number_of_days must be at least the total slot count;
  • 3 × days_in_month must be at least each monthly minimum;
  • the sum of minimum_slots_by_hour must not exceed total_slots;
  • the weekend minimum must not exceed the total or the number of eligible weekend rows; and
  • the sum of the three monthly minima must not exceed the total.

These checks do not prove joint feasibility, but they catch common specification errors with better messages than a generic solver status. For a production workflow, add an explicit infeasibility-analysis process and name the human owner authorised to revise each rule.

Run sensitivity as a decision analysis

One optimal plan under one synthetic scenario is not enough to understand a decision rule. Rerun a controlled grid that changes one assumption at a time:

  • increase or reduce the total monitoring budget;
  • tighten the maximum slots per day;
  • raise the weekend minimum;
  • change hourly coverage while preserving the same candidate snapshot; and
  • create a separately reviewed candidate snapshot for a forecast stress test.

The command-line entry point intentionally accepts only the committed scenario digest, so an unreviewed edit fails closed. For each sensitivity case, create a local review/ directory, save a separately named scenario there, review its diff, calculate and record its SHA-256, then open Julia in the pinned project with julia --startup-file=no --project=examples/programming-for-data-science/julia and call the library with that exact reviewed digest:

using HistoricalMonitoringPlan

mkpath("review")
reviewed_scenario_sha256 = "<paste the reviewed 64-character SHA-256>"
HistoricalMonitoringPlan.run_plan(
    "examples/programming-for-data-science/artifacts/predictions.csv",
    "examples/programming-for-data-science/julia/data/decision_candidates.csv",
    "review/scenario-sensitivity-01.json",
    "review/sensitivity-01-audit.json",
    "review/sensitivity-01-selected.csv";
    expected_scenario_sha256 = reviewed_scenario_sha256,
)

Do not set expected_scenario_sha256 to nothing for an analytical run; that escape hatch exists only for isolated negative tests. Scenario-only cases should keep the committed prediction and candidate paths shown above. A forecast stress test is a different paired handover: create a separately reviewed seven-column stress-prediction file, derive a corresponding candidate snapshot under the declared rounding rule, hash both, and pass those two new paths to run_plan. The reviewed scenario must carry the new candidate digest and the stress-prediction SHA as construction provenance. Preserve the schema, identities, split bounds, tolerance and outcome-exclusion invariant, and write each case to distinct outputs. Never let an ordinary clean refit silently replace the candidate file.

Compare selected-row overlap, objective score and which constraints bind. A small prediction change that replaces many slots is a stability signal, not proof that one plan is wrong. A large objective loss caused by a coverage constraint makes a real trade-off visible, but it does not tell the organisation whether the trade-off is worthwhile.

Keep scenario sensitivity separate from model evaluation. Test outcomes must not be searched to find the scenario that looks best retrospectively. If scenario design repeatedly uses realised test performance, that period has become development evidence and a new final evaluation boundary is needed.

Know what the optimum means

HiGHS can prove that no other binary selection has a larger objective under this exact formulation and its numerical tolerances. That is mathematical optimality, conditional on the model. It does not prove that:

  • the ridge predictions are calibrated for allocation decisions;
  • recorded rentals represent unconstrained transport demand;
  • high predicted volume is the right monitoring priority;
  • the synthetic budget or coverage rules match a real organisation;
  • monitoring changes service, safety or revenue; or
  • a plan for Washington, DC in late 2012 transfers to another place or time.

The UCI sample omits station capacity, bicycle and dock availability, rebalancing operations, prices and the cost or effect of monitoring. A production decision system would need current representative inputs, stakeholder-defined objectives, explicit harms and guardrails, forecast uncertainty, governance, accessibility, security, privacy and post-deployment evaluation.

The transferable lesson is narrower and more durable. Use Julia when a data-science handover needs an expressive, testable optimisation layer. Keep predictions distinct from assumptions. Formulate the decision in reviewable algebra. Check solver status, audit the extracted plan independently and publish a deterministic contract whose limitations are as explicit as its objective.

Primary references

Opens in a new tab.