# -*- coding: utf-8 -*-
"""
trade_study.py — TRADE STUDY 3: which business to start first.
OWNER: 01_System/trade_study.py
REVIEW: if a criterion, a weight or a score changes, supersede
02_Work/scratch/T3_trade_study_weights.md rather than editing it.
GOV-B6.1 three or more credible alternatives, including do-nothing
GOV-B6.3 weights fixed and timestamped BEFORE scoring — the record is
02_Work/scratch/T3_trade_study_weights.md, written 2026-09-07T06:22:16Z
GOV-B6.4 escalate rather than decide when the result is close
Every score cites the figure it rests on. A score without a figure is an opinion with a number
attached, which is the failure mode a trade study exists to prevent.
"""
def _three_year_net():
"""DEF-026. This figure was typed as $145,621 in a score rationale and in ASM-029, and no
function in the project produced it — the computed value is $144,421.10, found by independent
verification V6. A trade study whose whole design is 'every score cites the figure it rests on'
cannot cite a figure that does not exist, so it is computed here from the same builder the
business plan uses."""
import importlib.util, os
here = os.path.dirname(os.path.abspath(__file__))
spec = importlib.util.spec_from_file_location("bpj", os.path.join(here, "build_business_plan_json.py"))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
# DEF-026, then DEF-037. The first fix recomputed the net here from the plan's rounded cost
# and benefit fields, which produced $144,386.98 against the plan's own $144,421.10 — the
# inconsistency moved rather than closed. There is now ONE computed net, stored by the builder
# as cba.net_benefit, and every consumer reads that field instead of re-deriving it.
return "$%s" % format(mod.build()["cba"]["net_benefit"], ",.2f")
WEIGHTS_RECORD = "02_Work/scratch/T3_trade_study_weights.md (weights timestamped 2026-09-07T06:22:16Z, before any option was scored)"
# (key, criterion, weight)
CRITERIA = [
("capital", "Capital at risk before first revenue", 0.25),
("contrib", "Contribution per client per month", 0.25),
("time", "Time to first revenue", 0.20),
("stability", "Regulatory stability over three years", 0.15),
("owner", "Independence from the owner's own hours", 0.15),
]
OPTIONS = [
("A", "Aged care entity first; NDIS entity registers in parallel and takes clients second"),
("B", "NDIS entity first (core supports); aged care second"),
("C", "Support coordination only; no support workforce, no aged care"),
("D", "Do not start; remain employed"),
]
# option -> criterion -> (score 1-5, the figure the score rests on)
SCORES = {
"A": {
"capital": (3, "$17,117 six-month runway for the aged care entity with no owner draw, plus the "
"NDIS registration one-off carried in parallel. Middle of the field."),
"contrib": (5, "$1,002.06 per client per month against the NDIS entity's $435.30 — 2.3 times, on "
"a third of the service hours."),
"time": (4, "Contribution from month four on the modelled ramp, and break-even at 1.0 client. "
"Payment lag is about seven days rather than the NDIS plan-manager lag."),
"stability": (2, "Support at Home began November 2025; price caps were deferred indefinitely on 19 "
"May 2026 with no replacement date; the sector posted its first negative quarter. "
"The rules are being written while the business would be trading. [SRC-059, SRC-068]"),
"owner": (4, "Service delivery is worker time and scales. Care management is owner time but is "
"1.5 hours per client per month, so it does not bind until well past twelve clients."),
},
"B": {
"capital": (2, "$15,875 six-month runway, plus $7,319 of working capital at a fourteen-day lag "
"and $15,683 at thirty days — a gap the aged care entity does not carry at the "
"same scale."),
"contrib": (2, "$435.30 per client per month with paid administration. The gross margin of "
"$21.30 an hour is real but it is not what reaches the owner."),
"time": (2, "No billable hours before month four on the base ramp, cash positive at month "
"nine, and never within twelve months if administration is paid from the start."),
"stability": (4, "Mandatory registration from July 2027 is legislated and dated. It is unwelcome, "
"but a known date is more governable than an indefinite deferral. [SRC-014, SRC-015]"),
"owner": (3, "It scales on workers, but the administration break-point pins the owner: paid "
"administration does not pay for itself until 126.5 billable hours a month."),
},
"C": {
"capital": (5, "$3,673 one-off and an $8,757 six-month runway — about a third of either "
"operating business's one-off and about half its six-month runway, and no wage "
"cycle at all, so no working-capital gap. V6 corrected 'under a quarter', which "
"overstated the advantage by a factor of two; the score is unchanged because the "
"absence of a wage cycle, not the ratio, is what earns it."),
"contrib": (4, "$8,222 net a month at 55 per cent utilisation, $98,664 a year, on the owner's own "
"hours. Strong per dollar of capital; it has no second gear."),
"time": (5, "Mandatory registration for support coordination is currently paused, so this is "
"the fastest line to first revenue of the three. [SRC-018]"),
"stability": (1, "The NDIS Review recommended restructuring this exact line into a navigator model, "
"with design and pilot work running 2026 to 2028 and the funding mechanism intended "
"to change. The winner on speed is the line government has said it will redesign. "
"[SRC-049]"),
"owner": (1, "Every dollar is billed against an hour the owner personally works. It stops when "
"the owner stops. It is a job with a company number."),
},
"D": {
"capital": (5, "Nothing at risk. This is the only option where being wrong costs nothing."),
"contrib": (1, "No contribution. The modelled three-year net forgone is %s after the "
"owner's own foregone income is already charged as a cost." % _three_year_net()),
"time": (1, "There is no first revenue."),
"stability": (5, "Employment income is not exposed to either sector's reform programme."),
"owner": (5, "No dependence on the owner beyond turning up to a job that already exists."),
},
}
def weighted(weights=None):
w = dict(weights) if weights else {k: wt for k, _, wt in CRITERIA}
out = []
for key, label in OPTIONS:
total = sum(SCORES[key][c][0] * w[c] for c, _, _ in CRITERIA)
out.append((key, label, round(total, 3)))
return sorted(out, key=lambda r: -r[2])
def sensitivity():
"""GOV-B6.4. Find, for each criterion, the weight at which the ranking changes hands — taking
the difference from the criterion that currently carries the most weight against it. A trade
study that reports only its own answer is an argument, not a study."""
base = weighted()
winner = base[0][0]
runner = base[1][0]
findings = []
for key, label, wt in CRITERIA:
donor = "contrib" if key != "contrib" else "capital"
flip = None
step = 0.01
v = wt
while v <= 0.60:
w = {k: x for k, _, x in CRITERIA}
delta = v - wt
if w[donor] - delta < 0:
break
w[key] = v
w[donor] = w[donor] - delta
r = weighted(w)
if r[0][0] != winner:
flip = (round(v, 2), r[0][0], r[0][2], round(w[donor], 2))
break
v += step
findings.append((label, wt, flip, donor))
return base, winner, runner, findings
# =========================================================================
# TRADE STUDIES 1 AND 2 — BROUGHT UNDER THE SAME ENGINE
# -------------------------------------------------------------------------
# DEF-042. These two were rendered from hard-coded score strings in build_study.py, and trade
# study 1's published totals — 2.85 / 4.05 / 2.30 — could not be produced from its own published
# matrix at all. The correct totals under those weights and those scores are 3.50 / 4.10 / 3.50;
# the published A3 figure was out by 1.20 on a five-point scale, more than half its own value, and
# the stated margin of 1.20 was really 0.60. It survived three independent verification passes,
# two of which recorded that they had recomputed it by hand and found it correct. Independent
# verifier V8 did the arithmetic and found it did not close.
#
# Nothing is typed any more. Both studies now run through the same weighted() and sensitivity()
# functions as trade study 3, and checker C43 recomputes every published total from the register
# and fails on any disagreement. A number a reader could check by adding up four products is
# exactly the number a checker should be adding up.
# =========================================================================
TS1_CRITERIA = [
("capital", "Capital at risk before first revenue", 0.30),
("market", "Addressable market during the registration window", 0.25),
("durability", "Regulatory durability past July 2027", 0.25),
("burden", "Operational burden while the application runs", 0.20),
]
TS1_OPTIONS = [
("A1", "Register first, and trade only once the certificate is in hand"),
("A2", "Trade unregistered to plan-managed and self-managed participants from month one, and run the application in parallel"),
("A3", "Do not trade core supports before the certificate; use the window to run support coordination and build referrals"),
]
TS1_SCORES = {
"A1": {
"capital": (2, "No revenue at all for the four to six months the verification pathway takes, against a "
"$15,875 six-month runway with fixed costs running the whole time [SRC-021]."),
"market": (5, "Reaches agency-managed, plan-managed and self-managed participants from the first day of "
"trading, because the certificate is already held."),
"durability": (5, "The July 2027 gate is cleared by construction — there is no unregistered period to end."),
"burden": (2, "Audit preparation and the start of trading land in the same weeks, which is the worst "
"possible overlap for a one-person business."),
},
"A2": {
"capital": (5, "Revenue during the registration window. On the base ramp cash turns positive at month nine "
"instead of never within twelve months [ASM-015]."),
"market": (3, "Plan-managed and self-managed participants only. Agency-managed are unreachable until the "
"certificate arrives, and they are a large part of the cohort [SRC-016]."),
"durability": (5, "The same certificate on the same date; the only difference is that revenue starts earlier."),
"burden": (3, "Trading and audit preparation overlap, which is real work, but the audit evidence is easier "
"to produce when there are real clients to evidence it with."),
},
"A3": {
"capital": (4, "Support coordination needs $3,673 of one-off cost and carries no wage cycle, so the window "
"is funded — but at $8,222 a month at 55 per cent utilisation rather than a growing "
"core-supports ramp [SRC-005, ASM-013]."),
"market": (2, "No core-support clients at all during the window, and the referral relationships built are "
"for a different service line."),
"durability": (3, "The certificate arrives on time, but the line funding the window is the one the NDIS Review "
"proposes to redesign into a navigator model [SRC-049]."),
"burden": (4, "One service line at a time. This is the lightest operating load of the three."),
},
}
# NOT SCORED, and the reason is the point. GOV-B6.1 requires credible alternatives; an option the
# study's own evidence shows to be unlawful for most of the modelled period is not one.
TS1_ELIMINATED = [
("A4", "Remain unregistered indefinitely",
"Eliminated on evidence rather than on opinion, and NOT scored: mandatory registration reaches personal care "
"and daily living supports in July 2027 [SRC-015], so this is not an alternative for the period the study "
"models — it is the same business with an end date. Independent verifier V8 pointed out that scoring it had "
"been padding the alternative count with an option that could not be adopted."),
]
TS2_CRITERIA = [
("capital", "Capital to first revenue", 0.20),
("time", "Time to first revenue", 0.15),
("stability", "Regulatory stability through 2028", 0.25),
("margin", "Margin resilience against the sector evidence", 0.20),
("competition", "Competitive intensity and ease of the first client", 0.10),
("fit", "Owner-operator fit", 0.10),
]
TS2_OPTIONS = [
("CORE", "NDIS core supports — personal care and daily living"),
("SC", "NDIS support coordination"),
("SIL", "Supported Independent Living or Specialist Disability Accommodation"),
]
TS2_SCORES = {
"CORE": {"capital": (4, "$10,791 one-off, $15,875 six-month runway."),
"time": (5, "Billable from month four on the base ramp."),
"stability": (3, "Mandatory registration from July 2027 is dated and legislated [SRC-015]."),
"margin": (2, "$21.30 an hour becomes $6.70 with paid administration and negative at 0.40 hours [ASM-008]."),
"competition": (2, "The most crowded line, and the one the sector's loss-making providers are in."),
"fit": (4, "Scales on workers rather than on the owner's own hours.")},
"SC": {"capital": (5, "$3,673 one-off, $8,757 six-month runway, no wage cycle."),
"time": (4, "Registration for it is currently paused, so it is the fastest line to revenue [SRC-018]."),
"stability": (2, "The NDIS Review proposes redesigning it into a navigator model, 2026 to 2028 [SRC-049]."),
"margin": (4, "$98,664 a year at 55 per cent utilisation with no wage exposure [ASM-013]."),
"competition": (3, "Fewer providers, and the first client can come from an existing relationship."),
"fit": (5, "Billed against the owner's own hours, which is its strength and its ceiling.")},
"SIL": {"capital": (1, "$8,600 to $16,900 certification pathway plus property [SRC-034]."),
"time": (1, "Nine to twelve months of certification before a single billable hour [SRC-021]."),
"stability": (3, "Registration is already mandatory, so there is no pathway left to close."),
"margin": (4, "The highest published rates in the price guide, on 24/7 rostering."),
"competition": (2, "National SDA sits at 53.7 per cent utilisation against a surplus of 4,638 places [SRC-050]."),
"fit": (1, "Property, 24/7 rostering and a workforce from day one. Not a one-person start.")},
}
STUDIES = {
"TS1": (TS1_CRITERIA, TS1_OPTIONS, TS1_SCORES,
"02_Work/scratch/T1_trade_study_weights.md (weights timestamped 2026-08-20T09:40 AEST, scores at 11:05)"),
"TS2": (TS2_CRITERIA, TS2_OPTIONS, TS2_SCORES,
"DEC-003 (weights recorded 2026-08-20T09:40 AEST, scores at 11:05)"),
"TS3": (CRITERIA, OPTIONS, SCORES, WEIGHTS_RECORD),
}
def study_weighted(key, weights=None):
crit, opts, scores, _ = STUDIES[key]
w = dict(weights) if weights else {k: wt for k, _, wt in crit}
out = [(ok, label, round(sum(scores[ok][c][0] * w[c] for c, _, _ in crit), 3))
for ok, label in opts]
return sorted(out, key=lambda r: -r[2])
def study_margin(key):
r = study_weighted(key)
return round(r[0][2] - r[1][2], 3)
def study_sensitivity(key):
"""Where does this study's answer change hands? Same method as trade study 3: move one
criterion's weight and take the difference from the criterion that most opposes it."""
crit, opts, scores, _ = STUDIES[key]
base = study_weighted(key)
winner = base[0][0]
findings = []
names = {k: lab for k, lab, _ in crit}
for key_c, label, wt in crit:
best = None
# Try EVERY other criterion as the donor and keep the smallest weight movement that
# changes the answer. Fixing one donor in advance hides flips that exist, which is how a
# sensitivity statement ends up saying "no weight changes this" when several do.
for donor, _, dwt in crit:
if donor == key_c:
continue
v = wt
while v <= 0.80:
w = {k: x for k, _, x in crit}
delta = v - wt
if w[donor] - delta < 0:
break
w[key_c] = v
w[donor] = w[donor] - delta
r = study_weighted(key, w)
if r[0][0] != winner:
cand = (round(v, 2), r[0][0], r[0][2], names[donor])
if best is None or cand[0] < best[0]:
best = cand
break
v += 0.01
findings.append((label, wt, best))
return base, findings
def margin():
b = weighted()
return round(b[0][2] - b[1][2], 3)
if __name__ == "__main__":
base, winner, runner, findings = sensitivity()
print("WEIGHTS RECORD:", WEIGHTS_RECORD)
for k, l, s in base:
print(" %s %5.2f %s" % (k, s, l[:70]))
print("margin between first and second:", margin())
for label, wt, flip, donor in findings:
if flip:
print(" FLIPS: %-42s %.2f -> %.2f (from %s) hands it to %s at %.2f"
% (label[:42], wt, flip[0], donor, flip[1], flip[2]))
else:
print(" holds: %-42s no weight up to 0.60 changes the winner" % label[:42])