Skip to content

Instantly share code, notes, and snippets.

@timm
Last active June 27, 2026 01:38
Show Gist options
  • Select an option

  • Save timm/6bc85bc848a516f111bd89ad501a7004 to your computer and use it in GitHub Desktop.

Select an option

Save timm/6bc85bc848a516f111bd89ad501a7004 to your computer and use it in GitHub Desktop.
xomo: Monte-Carlo COCOMO-II + COQUALMO (effort/defects/risk) over 4 case studies. http://tiny.cc/xomo

AuthorLanguageDepsLicensePurpose

xomo: one short file that runs COCOMO-II (effort), COQUALMO (defects) and Boehm's risk tables (schedule/cost risk) as a Monte-Carlo over four classic NASA/JPL case studies (flight, ground, osp, osp2). A project is a box of rating ranges; sample inside it many times and read effort, defects and risk as distributions, not single guesses. The model is pure stdlib; --learn grows a min-variance decision tree over the draws using nuff.

# install and test
git clone http://tiny.cc/xomo && cd xomo
python3 -B xomo.py             # medians (p25 p50 p75) per study
python3 -B xomo.py --learn     # decision tree: what drives good outcomes
python3 -B xomo.py --checks    # self-tests

Sections: NAME | SYNOPSIS | OPTIONS | DATA | TESTS | OUTPUT | LEARN | CALIBRATION | SEE ALSO | LICENSE | AUTHOR

Files: xomo.py | Makefile | pyproject.toml

NAME

xomo - Monte-Carlo COCOMO-II + COQUALMO (effort, defects, risk)

SYNOPSIS

python3 -B xomo.py [-s N] [-n N] [-l N] [-p STUDY] [--learn] [--checks] [-h]

OPTIONS

-s --seed     random seed                   seed=1
-n --n        samples/study, or --learn rows  n=1000
-l --leaf     min rows per tree leaf         leaf=3
-p --project  study to --learn               project=osp
   --learn    grow a decision tree (needs nuff)
   --checks   run model self-tests
-h --help     show the docstring

DATA

A case study is a dict of COCOMO driver ranges. Any driver left unset spans its full legal range. Drivers fall in four families:

family   members                              role
------   -----------------------------------  ----------------------
sf       prec flex resl team pmat             scale factors (exponent)
em(+)    rely data cplx ruse docu time stor   effort up as rating up
         pvol
em(-)    acap pcap pcon aexp plex ltex tool   effort down as rating up
         site sced
dr       aa etat pr                           defect removers

Each draw: pick an integer rating in every driver's box, then

effort  = a * KLOC^(b + 0.01*sum SF) * prod EM        (COCOMO-II)
defects = sum over {reqs,design,code} of
          introduced(SF,EM) * (1 - removed(dr))        (COQUALMO)
risk    = 100 * (fired Boehm risk-table cells) / 216

TESTS

--checks runs every test_*:

test_positive                 effort/defects/risk finite, in range
test_osp_riskier_than_osp2    maturing osp->osp2 cuts risk
test_flight_bigger_than_ground tighter flight s/w costs more
test_seed_repeats             same seed -> same draw
test_learn_tree               nuff tree finds at least one split

OUTPUT

# 1000 samples/study, seed=1
                          effort               defects               risk
  study      p25     p50     p75     p25    p50    p75    p25   p50   p75
 flight      921    1971    3672    1606   3508   6939      4     6     7
 ground      558    1144    2123    1508   3471   6846      2     3     5
    osp     1743    2240    3014    3589   5511   8161     12    16    20
   osp2      237     307     386     131    205    298      3     3     4

Note osp -> osp2: process maturity (prec, pmat up) drops median effort ~7x, defects ~27x, and risk from 16 to 3 -- the "orders of magnitude" effect, shown reproducibly.

LEARN

--learn turns N random draws of one project into a table (driver ratings -> effort/defects/risk) and grows nuff's min-variance decision tree over it. The root split = the single factor that most separates good from bad outcomes; follow branches to the + leaf for the rating combo that minimises the blended goals.

python3 -B xomo.py --learn -p osp2 -n 200 -l 20

   d2h    n  Effort-  Defects-  Risk-  tree
  0.51  200   324.21    228.95   3.48              <- baseline (all draws)
  0.45  148   317.55    224.21   3.12  Sced > 2
  0.37   79   260.86    194.78   3.11  |  Kloc <= 102
+ 0.31   36   231.56    170.25      3  |  |  Ltex > 3
  ...

Needs nuff: pip install nuff, or clone the sibling gist so $DOOT/nuff is importable. -n sets rows, -l the min leaf size.

CALIBRATION

Each draw guesses a fresh internal calibration (effort a,b, the EM/SF slopes, COQUALMO m-ranges). So 1000 runs marginalize over the space of plausible calibrations: a conclusion that survives is stable no matter which calibration is "true" -- letting you decide without local tuning data. This is the core XOMO idea (Menzies & Richardson, 2005). Tweak the ranges in the POLICY block to widen or narrow that space.

SEE ALSO

konfig    http://tiny.cc/konfig   shared Makefile/boilerplate
nuff      http://tiny.cc/nuff     tiny stdlib python tricks
paper     Menzies & Richardson, "XOMO: Understanding Development
          Options for Autonomy", 20th Intl Forum on COCOMO, 2005.
          http://timmenzies.net/pdf/05xomo.pdf

LICENSE

MIT. https://choosealicense.com/licenses/mit/

AUTHOR

Tim Menzies, timm@ieee.org

__pycache__/
*.pyc
.ruff_cache/
dist/
build/
*.egg-info/
payload.json
gist.json
__ _____ _ __ ___ ___
\ \/ / _ \| '_ ` _ \ / _ \
> < (_) | | | | | | (_) |
/_/\_\___/|_| |_| |_|\___/
COCOMO-II + COQUALMO. http://tiny.cc/xomo
"effort, defects, risk -- as ranges, not single numbers."
# vim: ts=2 sw=2 sts=2 et :
# knobs only; shared targets live in $(KONFIG)/Makefile
KONFIG ?= ../konfig
APP := xomo
MAIN := xomo.py
EXT := py
LANG := python
SRC := *.py
LINT := ruff check xomo.py
TOOLS := python3:run ruff:lint
PKG := python3 gawk ruff neovim tmux
$(KONFIG)/Makefile:
@test -f $@ || { echo "missing konfig: git clone http://tiny.cc/konfig $(KONFIG)"; exit 1; }
include $(KONFIG)/Makefile
DEMO: ## test: medians for all 4 case studies print
@python3 -B xomo.py | grep -q osp2 && echo "ok demo"
CHECKS: ## test: model self-checks pass
@python3 -B xomo.py --checks | grep -q "4/4 ok" && echo "ok checks"
test: ## run every UPPERCASE rule
@gawk -F: '/^[A-Z][A-Z_]*:[^=]/ {print $$1}' $(MAKEFILE_LIST) | \
sort -u | while read t; do \
printf "\n=== %s ===\n" "$$t"; $(MAKE) -s $$t; done
# xomo's terse style on purpose: 2-space indent, one-line guards,
# semicolons, inline lambdas (see konfig/style_code.md ZIP + BAIL).
[tool.ruff]
line-length = 80
[tool.ruff.lint]
ignore = [
"E701", # one-line if/for/def colon bodies
"E702", # `a; b` multiple statements
"E731", # `f = lambda ...` assignments
"E741", # short names like l/o/i
"E401", # grouped `import a, b, c`
]
#!/usr/bin/env python3 -B
"""
xomo.py, Monte-Carlo COCOMO-II + COQUALMO over 4 case studies (cli)
(c) 2026, Tim Menzies <timm@ieee.org>, MIT license
Each project is a box of rating ranges (uncertainty). Sample inside
that box many times; each draw yields an effort (COCOMO-II), a defect
count (COQUALMO), and a schedule/cost risk index (Boehm risk tables).
The spread of medians shows what is, and is not, settled about a job.
Split on purpose: POLICY = tweakable domain tables (drivers, bounds,
defect signs, calibration ranges, risk tables, case studies).
MECHANISM = generic engine that reads policy, hard-codes no numbers.
Options:
-s --seed random seed seed=1
-n --n samples/study or --learn rows n=1000
-l --leaf min rows per tree leaf leaf=3
-p --project study to --learn project=osp
eg: python3 xomo.py # medians: flight ground osp osp2
python3 xomo.py --learn -p osp2 -n 128 # nuff tree over 128 draws
python3 xomo.py --checks # self-tests
needs nuff (the shared learner lib): pip install nuff, or clone the
sibling gist (http://tiny.cc/nuff) so $DOOT/nuff is importable.
"""
import os, re, sys, random, statistics
from random import uniform
from types import SimpleNamespace as o
try: # nuff = shared learner lib (pip install nuff)
from nuff import Data, tree, treeShow
except ImportError: # else the sibling gist under $DOOT (gists root)
sys.path.append(os.path.join(os.environ.get("DOOT") or
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "nuff"))
from nuff import Data, tree, treeShow
# ===== POLICY: business knowledge -- tweak freely ==============
# One row per COCOMO driver: name kind lo-hi defect-sign
# kind sf=scale factor emp/emn=effort mult (+/- slope) dr=defect remover
# sign +=more defects -=fewer .=none ~=none in reqs, fewer after
DRIVERS = """
prec sf 1-5 - flex sf 1-5 . resl sf 1-5 -
team sf 1-5 - pmat sf 1-5 -
rely emp 1-5 - data emp 2-5 + cplx emp 1-6 +
ruse emp 2-6 + docu emp 1-5 - time emp 3-6 +
stor emp 3-6 + pvol emp 2-5 +
acap emn 1-5 - pcap emn 1-5 ~ pcon emn 1-5 -
aexp emn 1-5 - plex emn 1-5 - ltex emn 1-5 -
tool emn 1-5 - site emn 1-6 - sced emn 1-5 -
aa dr 1-6 . etat dr 1-6 . pr dr 1-6 .
"""
# COCOMO-II effort calibration RANGES, sampled fresh per draw. The
# point of XOMO: each run guesses a different internal calibration, so
# 1000 runs marginalize over the space of plausible calibrations --
# conclusions that survive are stable without local tuning data.
EFFORT = o(a =(2.25, 3.25), # linear tuning (paper Fig 2)
bhi=1.1, blo=0.9, # exponent base: b falls as a rises
emp=( 0.073, 0.21), # +slope effort mult, per rating step
emn=(-0.178, -0.078),# -slope effort mult
sf =( 1.014, 1.6)) # scale-factor weight
# COQUALMO: phase -> (defects/KLOC base, intro+ m, intro- m, removal m)
PHASES = dict(
reqs =(10, (0.0166, 0.38 ), (-0.215, -0.035), (0.0, 0.14 )),
design=(20, (0.0066, 0.145), (-0.325, -0.050), (0.0, 0.156)),
code =(30, (0.0066, 0.145), (-0.290, -0.050), (0.1, 0.176)))
# Boehm risk tables, laid out vertical so the nonzero "corner" shows.
# A cell [worse-of-a][worse-of-b] = risk points; '.' = no risk.
def grid(s):
"Read a 6x6 risk table: '.'->0, digit->points."
return [[0 if c == "." else int(c) for c in row.split()]
for row in s.strip().splitlines()]
ne = grid("""
. . . 1 2 4
. . . . 1 2
. . . . . 1
. . . . . .
. . . . . .
. . . . . .""")
ne86 = grid("""
. . 1 2 4 8
. . . 1 2 4
. . . . 1 2
. . . . . 1
. . . . . .
. . . . . .""")
nw = grid("""
4 2 1 . . .
2 1 . . . .
1 . . . . .
. . . . . .
. . . . . .
. . . . . .""")
nw8 = grid("""
8 4 2 1 . .
4 2 1 . . .
2 1 . . . .
1 . . . . .
. . . . . .
. . . . . .""")
sw = grid("""
. . . . . .
1 . . . . .
2 1 . . . .
4 2 1 . . .
. . . . . .
. . . . . .""")
sw8 = grid("""
. . . . . .
1 . . . . .
2 1 . . . .
4 2 1 . . .
8 4 2 1 . .
. . . . . .""")
sw26 = grid("""
. . . . . .
. . . . . .
1 . . . . .
2 1 . . . .
4 2 1 . . .
. . . . . .""")
sw86 = grid("""
. . . . . .
1 . . . . .
2 1 . . . .
4 2 1 . . .
8 4 2 1 . .
. . . . . .""")
# which driver-pairs fire which table
RISK = dict(
ltex=dict(pcap=nw8), pvol=dict(plex=sw),
pmat=dict(acap=nw, pcap=sw86), ruse=dict(aexp=sw86, ltex=sw86),
stor=dict(acap=sw86, pcap=sw86),
cplx=dict(acap=sw86, pcap=sw86, tool=sw86),
rely=dict(acap=sw8, pcap=sw8, pmat=sw8),
team=dict(aexp=nw, sced=nw, site=nw),
time=dict(acap=sw86, pcap=sw86, tool=sw26),
tool=dict(acap=nw, pcap=nw, pmat=nw),
sced=dict(cplx=ne86, time=ne86, pcap=nw8, aexp=nw8, acap=nw8,
plex=nw8, ltex=nw, pmat=nw, rely=ne, pvol=ne, tool=nw))
# the 4 case studies (rating ranges; absent driver spans full bounds)
CASE = dict(
flight = dict(kloc=(7,418), pmat=(2,3), rely=(3,5), data=(2,3),
cplx=(3,6), time=(3,4), stor=(3,4), acap=(3,5), aexp=(2,5),
pcap=(3,5), plex=(1,4), ltex=(1,4), tool=(2,2), sced=(3,3)),
ground = dict(kloc=(11,392), pmat=(2,3), rely=(1,4), data=(2,3),
cplx=(1,4), time=(3,4), stor=(3,4), acap=(3,5), aexp=(2,5),
pcap=(3,5), plex=(1,4), ltex=(1,4), tool=(2,2), sced=(3,3)),
osp = dict(kloc=(75,125), prec=(1,2), flex=(2,5), resl=(1,3),
team=(2,3), pmat=(1,4), stor=(3,5), ruse=(2,4), docu=(2,4),
cplx=(5,6), data=(3,3), pvol=(2,2), rely=(5,5), acap=(2,3),
pcon=(2,3), aexp=(2,3), ltex=(2,4), tool=(2,3), sced=(1,3),
pcap=(3,3), plex=(3,3), site=(3,3)),
osp2 = dict(kloc=(75,125), prec=(3,5), pmat=(4,5), flex=(3,3),
resl=(4,4), team=(3,3), docu=(3,4), ltex=(2,5), sced=(2,4),
time=(3,3), stor=(3,3), data=(4,4), pvol=(3,3), ruse=(4,4),
rely=(5,5), cplx=(4,4), acap=(4,4), pcap=(3,3), pcon=(3,3),
aexp=(4,4), plex=(4,4), tool=(5,5), site=(6,6)))
# ===== MECHANISM: generic engine -- reads policy, no numbers ===
# parse DRIVERS into the lookups the engine uses
KIND, BND, SIGN = {}, {}, {}
def _load(table):
toks = table.split()
for i in range(0, len(toks), 4):
name, kind, rng, sign = toks[i:i+4]
lo, hi = map(int, rng.split("-"))
KIND[name], BND[name], SIGN[name] = kind, (lo, hi), sign
_load(DRIVERS)
def project(case):
"Fill every driver with a (lo,hi) box: case value, else default."
box = {n: case.get(n, BND[n]) for n in KIND}
return o(kloc=case["kloc"], box=box)
def sample(proj):
"One draw: integer rating per driver + a real kloc."
z = {n: round(uniform(lo, hi)) for n, (lo, hi) in proj.box.items()}
return z, uniform(*proj.kloc)
def effort(z, kloc):
"COCOMO-II: a * KLOC^(b + .01*sum SF) * prod EM."
lo, hi = EFFORT.a
a = uniform(lo, hi)
b = EFFORT.bhi + (EFFORT.blo-EFFORT.bhi) * (a-lo)/(hi-lo) # b depends on a
prod, ssum = 1.0, 0.0
for n, kind in KIND.items():
if kind == "emp": prod *= 1 + (z[n]-3) * uniform(*EFFORT.emp)
elif kind == "emn": prod *= 1 + (z[n]-3) * uniform(*EFFORT.emn)
elif kind == "sf": ssum += (6 - z[n]) * uniform(*EFFORT.sf)
return a * kloc ** (b + 0.01*ssum) * prod
def intro1(name, z, phase, pos, neg):
"How one driver scales introduced defects (1.0 = no effect)."
s = SIGN[name]
if s == "+": return uniform(*pos) * (z-3) + 1
if s == "-" or (s == "~" and phase != "reqs"):
return uniform(*neg) * (z-3) + 1
return 1.0
def defects(z, kloc):
"COQUALMO: sum over phases of introduced * (1 - removed)."
total = 0.0
for phase, (base, pos, neg, rem) in PHASES.items():
intro = base * kloc
for n, kind in KIND.items():
if kind in ("sf", "emp", "emn"):
intro *= intro1(n, z[n], phase, pos, neg)
kept = 1.0
for n, kind in KIND.items():
if kind == "dr": kept *= 1 - uniform(*rem) * (z[n] - 1)
total += intro * kept
return total
def risk(z):
"Sum of fired risk cells, scaled to a 0..100 index."
hits = sum(RISK[a][b][z[a]-1][z[b]-1]
for a in RISK for b in RISK[a])
return 100 * hits / 216
def run(proj):
z, kloc = sample(proj)
return o(effort=effort(z, kloc), defects=defects(z, kloc), risk=risk(z))
# ## report -----------------------------------------------------
def quart(xs):
xs = sorted(xs); n = len(xs)
return xs[n//4], xs[n//2], xs[3*n//4]
def med(xs): return statistics.median(xs)
def report(the):
print(f"# {the.n} samples/study, seed={the.seed}")
lmh = lambda w: "%*s %*s %*s" % (w, "p25", w, "p50", w, "p75")
print(f"{'':>7} {'effort':>23} {'defects':>20} {'risk':>17}")
print(f"{'study':>7} {lmh(7)} {lmh(6)} {lmh(5)}")
for name in "flight ground osp osp2".split():
random.seed(the.seed)
rows = [run(project(CASE[name])) for _ in range(the.n)]
e = "%7.0f %7.0f %7.0f" % quart([r.effort for r in rows])
d = "%6.0f %6.0f %6.0f" % quart([r.defects for r in rows])
k = "%5.0f %5.0f %5.0f" % quart([r.risk for r in rows])
print(f"{name:>7} {e} {d} {k}")
# ## learn: nuff's min-variance tree over random draws ----------
def table(proj, n=128):
"n random draws as a nuff Data: ratings + kloc -> effort/defects/risk."
head = [d.capitalize() for d in KIND] + \
["Kloc", "Effort-", "Defects-", "Risk-"] # -=minimise goal
rows = []
for _ in range(n):
z, kloc = sample(proj)
rows.append([z[d] for d in KIND] + [round(kloc),
round(effort(z, kloc)), round(defects(z, kloc)),
round(risk(z))])
return Data([head] + rows)
def coachReport(the):
"Learn a min-variance tree (nuff) over -n random draws of a project."
random.seed(the.seed)
data = table(project(CASE[the.project]), the.n)
print(f"# {the.project}: nuff tree over {the.n} random draws. leaf disty"
" blends effort/defects/risk (lower=better); +best -worst leaf.")
treeShow(data, tree(data, leaf=the.leaf))
# ## checks (run via --checks) ----------------------------------
def test_positive():
"effort, defects, risk are all non-negative and finite."
random.seed(1); proj = project(CASE["ground"])
rows = [run(proj) for _ in range(200)]
assert all(r.effort > 0 and r.defects >= 0 and 0 <= r.risk <= 100
for r in rows)
def test_osp_riskier_than_osp2():
"Literature claim: maturing osp->osp2 cuts risk."
random.seed(1)
r1 = med([run(project(CASE["osp"])).risk for _ in range(400)])
r2 = med([run(project(CASE["osp2"])).risk for _ in range(400)])
assert r1 > r2, (r1, r2)
def test_flight_bigger_than_ground():
"Flight software (tighter constraints) costs more than ground."
random.seed(1)
f = med([run(project(CASE["flight"])).effort for _ in range(400)])
g = med([run(project(CASE["ground"])).effort for _ in range(400)])
assert f > g, (f, g)
def test_seed_repeats():
"Same seed -> same draw."
random.seed(42); a = run(project(CASE["osp"])).effort
random.seed(42); b = run(project(CASE["osp"])).effort
assert a == b
def test_learn_tree():
"nuff tree over draws finds at least one split."
random.seed(1)
assert tree(table(project(CASE["osp"]))).at is not None
# ## lib + cli --------------------------------------------------
def settings(s):
"Parse every var=val pair in a string into an o (ints coerced)."
f = lambda v: int(v) if re.fullmatch(r"-?\d+", v) else v
return o(**{k: f(v) for k, v in re.findall(r"(\w+)=(\S+)", s)})
def main(the, g):
argv = sys.argv[1:]
# accept -n N / --n N / --n=N (int), -p NAME / --project=NAME (str),
# or a bare study name (osp, osp2, ...). same flags for seed.
ints = {"-n": "n", "--n": "n", "-s": "seed", "--seed": "seed",
"-l": "leaf", "--leaf": "leaf"}
i = 0
while i < len(argv):
a = argv[i]
if a in CASE: the.project = a
elif "=" in a:
k, v = a.lstrip("-").split("=")
if k == "project": the.project = v
elif k in ("n", "seed", "leaf"): setattr(the, k, int(v))
elif a in ("-p", "--project") and i+1 < len(argv):
the.project = argv[i+1]; i += 1
elif a in ints and i+1 < len(argv):
setattr(the, ints[a], int(argv[i+1])); i += 1
i += 1
if "-h" in argv or "--help" in argv: return print((__doc__ or "").strip())
if "--checks" in argv:
tests = {k: v for k, v in g.items() if k.startswith("test_")}
ok = 0
for name, fn in tests.items():
try: fn(); ok += 1; print("PASS", name)
except Exception as e: print("FAIL", name, e)
return print(f"# {ok}/{len(tests)} ok")
if "--learn" in argv: return coachReport(the)
report(the)
the = settings(__doc__)
if __name__ == "__main__":
main(the, globals())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment