First public release

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 14:45:34 -04:00
co-authored by Claude Opus 5.5
commit e8af6d7e94
17 changed files with 2761 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
*.log
.DS_Store
.env*
!.env.example
.npmrc
dist-cjs/
+6
View File
@@ -0,0 +1,6 @@
stages: [test]
test:
stage: test
image: node:22-alpine
script: [npm ci, npm run build, npm test]
+18
View File
@@ -0,0 +1,18 @@
# Changelog
## 0.1.0 - 2026-09-28
First public release under MIT.
- `synthesize()` and `formatSynthesis()` check a mission concept's
requirements, architecture options and TRL claim.
- Derivation is checked both ways: invented requirements, unaddressed needs
and parent ids that do not exist.
- Requirements with no verification method, or an undecidable criterion,
fail.
- The claimed TRL is capped by the strongest recorded evidence, and no
evidence returns `null`, not TRL 1. `maturationPath()` runs from the
effective level.
- Trade-study checks: a single option is not assessed, options identical on
every criterion fail, flat criteria and dominated options are noted, and
requirements no option satisfies fail.
+22
View File
@@ -0,0 +1,22 @@
# Contributing
Issues and merge requests are welcome at
https://gitlab.com/extant2000/mission-synth.
## Ground rules
- **A test that cannot fail proves nothing.** If you fix a bug, add a test
that fails without your fix, and check that it does fail before you submit.
- Measure, do not assume. Two modules with the same line count can be
different programs.
- Keep dependencies minimal. Every new dependency needs a reason.
- Explain why in comments, not what.
## Before you open a merge request
Run both of these and make sure they pass:
```
npm run build
npm test
```
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Extant 2000 LLC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+144
View File
@@ -0,0 +1,144 @@
# MissionSynth
Checks that a mission concept holds together: requirements traced to needs in
both directions, verification criteria someone can decide, architecture
options that differ, and a TRL claim capped by evidence.
## What it does and why
MissionSynth does not write requirements. Turning a concept into candidate
requirements and architectures is generative work, done by people or by a
model. What decides whether that output survives a review is whether it holds
together, and that part can be checked:
- every requirement traces to a stated need, and every need to a requirement;
- every requirement names a verification method and a criterion someone could
decide;
- the architecture options differ on at least one stated criterion;
- the claimed TRL does not exceed what the evidence demonstrates.
It is pure: no clock, no I/O and no model calls. Reports use the
`@extant2000/evidence-record` format. The requirement id is the key a
downstream traceability tool can pick up.
## Derivation, checked both ways
The two defects a requirement set tends to have are opposites, and a check in
one direction finds only one of them.
| Defect | Typical of |
|---|---|
| Invented: traces to no stated need | A generated set. Producing plausible requirements is easy; producing only warranted ones is not. |
| Unaddressed need: no requirement covers it | A hand-written set. The needs that get forgotten are the ones nobody enjoys thinking about. |
A parent id that does not exist (`dangling-parent`) is its own defect. It is
the same as no parent, and it looks like traceability.
A requirement with no verification method fails, and so does one whose
criterion cannot be decided. A requirement nobody can fail passes every
review. `isMeasurable()` is a smell test, not a grammar. It catches the common
cases ("user-friendly", "acceptable", "intuitive"), but a criterion can be
unmeasurable without using any of those words, so a clean result does not
prove the criterion is good.
## TRL, capped by evidence
Technology Readiness Level is usually self-assessed, and it only goes wrong in
one direction: nobody claims a level below what they can show. The
definitions are specific, which is what makes this checkable. Each level names
the environment something was demonstrated in, and an environment is a fact.
```
TRL 4 component validated in a laboratory
TRL 5 component validated in a relevant environment
TRL 6 system or subsystem model demonstrated in a relevant environment
TRL 7 system prototype demonstrated in an operational environment
```
`EVIDENCE_CEILING` maps each kind of demonstration to the highest level it can
support. A bench test or a unit test (`lab-bench`) supports TRL 3, so a TRL 6
claim backed by one is capped at 3. The best evidence sets the ceiling:
having also written a paper does not drag down a system demonstration. No
evidence at all returns `null`, not TRL 1, because TRL 1 is itself a claim
that principles were observed and reported.
`maturationPath()` runs from the effective level, not the claimed one, and
each step names the demonstration that raises the number. A roadmap whose
steps produce no evidence is a schedule.
## A trade study with nothing to trade
- **One option** is a decision already made. It is reported as not assessed,
whatever the document is called.
- Options identical on every criterion fail at critical severity. The table is
full, the columns are populated, and the choice rests on something not
written down, which is why this passes a quick review.
- Criteria that are the same across all options are named as doing no work in
the decision. That is a note, not a fault.
- An option that satisfies a strict subset of another's requirements and adds
nothing of its own is flagged as dominated. It may still win on cost, but
carrying it unexamined makes the study look broader than it is.
- A requirement that no option satisfies fails.
## Usage
```ts
import { synthesize, formatSynthesis, type MissionConcept } from '@extant2000/mission-synth'
const concept: MissionConcept = {
name: 'deorbit module',
needs: [
{ id: 'N-1', statement: 'deorbit within 25 years of end of mission',
source: 'debris mitigation guideline', where: 'concept/needs.md' },
{ id: 'N-2', statement: 'survive launch loads', source: 'launch provider', where: 'concept/needs.md' },
],
requirements: [
{ id: 'SYS-001', statement: 'The system shall deorbit within 25 years', derivedFrom: ['N-1'],
verification: { method: 'analysis', criterion: 're-entry at less than 25 years' },
where: 'concept/requirements.md' },
{ id: 'SYS-002', statement: 'The system shall be easy to integrate', derivedFrom: [],
verification: { method: 'inspection', criterion: 'integration is intuitive' },
where: 'concept/requirements.md' },
],
options: [
{ id: 'A', name: 'drag sail', satisfies: ['SYS-001'], criteria: { mass: 'low', cost: 2 }, where: 'trade/a.md' },
{ id: 'B', name: 'propulsive', satisfies: ['SYS-001'], criteria: { mass: 'low', cost: 2 }, where: 'trade/b.md' },
],
claimedTrl: 6,
targetTrl: 7,
maturity: [{ evidence: 'lab-bench', description: 'deployment mechanism cycled on the bench', where: 'test/TR-12' }],
}
const report = synthesize(concept)
console.log(formatSynthesis(concept))
```
The finding lines from that output (header, notes and detail lines omitted):
```
✗ CRITICAL 1 requirement(s) satisfied by no option: SYS-002 [NOT MET]
✗ CRITICAL No criterion tells the 2 options apart [NOT MET]
✗ CRITICAL N-2 is stated and no requirement covers it [NOT MET]
✗ CRITICAL SYS-002: invented [NOT MET]
✗ CRITICAL TRL 6 claimed, evidence supports TRL 3 [NOT MET]
✗ HIGH SYS-002: immeasurable [NOT MET]
◦ info SYS-001 traces to N-1 and is verifiable by analysis [MET]
```
The notes also give the maturation path to TRL 7: four steps starting from
the effective TRL 3, not one step from the claimed TRL 6.
The building blocks are exported too: `requirementIssues()`,
`unaddressedNeeds()`, `isMeasurable()`, `nonDiscriminating()`,
`isUndiscriminated()`, `dominatedOptions()`, `uncoveredRequirements()`,
`supportedTrl()`, `assessTrl()` and `maturationPath()`.
## Install
```
npm install @extant2000/mission-synth
```
## License
MIT. See [LICENSE](LICENSE).
+6
View File
@@ -0,0 +1,6 @@
# Security
Please report vulnerabilities privately by email to security@extant2000.com.
Do not open a public issue for a security problem.
We aim to acknowledge every report within 5 business days.
+1503
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "@extant2000/mission-synth",
"version": "0.1.0",
"private": false,
"description": "Checks a mission concept: requirement traceability both ways, decidable verification, discriminated options and a TRL capped by evidence.",
"license": "MIT",
"type": "module",
"main": "./dist-cjs/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist",
"dist-cjs",
"src",
"README.md",
"LICENSE"
],
"repository": {
"type": "git",
"url": "https://gitlab.com/extant2000/mission-synth.git"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist-cjs/index.js"
}
},
"scripts": {
"build": "tsc && tsc -p tsconfig.cjs.json && node -e \"require('fs').writeFileSync('dist-cjs/package.json', JSON.stringify({type:'commonjs'}))\"",
"test": "vitest run",
"prepublishOnly": "npm run build"
},
"dependencies": {
"@extant2000/evidence-record": "^0.1.2"
},
"devDependencies": {
"typescript": "^5.6.0",
"vitest": "^2.1.0"
},
"author": "Extant 2000 LLC",
"homepage": "https://gitlab.com/extant2000/mission-synth",
"bugs": {
"url": "https://gitlab.com/extant2000/mission-synth/-/issues"
}
}
+97
View File
@@ -0,0 +1,97 @@
/**
* Architecture options, checked for whether they are actually options.
*
* A trade study is theatre in two recognisable ways, and both survive review
* because the document looks right:
*
* 1. **One option.** A single architecture presented with a rationale is a
* decision that has already been made. There is nothing to trade.
*
* 2. **Options that do not differ.** Two or three alternatives that score
* identically on every stated criterion. The table is full, the columns
* are populated, and no criterion discriminates — so the choice between
* them rests on something not written down. That is the case worth
* catching, because it is indistinguishable from real analysis at a
* glance.
*
* Separately: an option that satisfies fewer requirements than another is
* not automatically worse — it may be cheaper or simpler on a criterion that
* matters more. But an option satisfying NO requirement the others do not,
* while satisfying strictly fewer, is dominated, and carrying it makes the
* study look broader than it is.
*/
export interface ArchitectureOption {
id: string
name: string
/** Requirement ids this option satisfies. */
satisfies: string[]
/**
* Scores or values per criterion, keyed by criterion name. Numbers or
* short labels — whatever the study actually recorded.
*/
criteria: Record<string, string | number>
where: string
}
/** Criteria on which every option holds the same value. */
export function nonDiscriminating(options: ArchitectureOption[]): string[] {
const list = options ?? []
if (list.length < 2) return []
const names = [...new Set(list.flatMap(o => Object.keys(o.criteria)))]
return names.filter(name => {
const values = list.map(o => String(o.criteria[name] ?? '∅'))
return new Set(values).size === 1
}).sort()
}
/** True when no criterion tells any two options apart. */
export function isUndiscriminated(options: ArchitectureOption[]): boolean {
const list = options ?? []
if (list.length < 2) return false
const names = [...new Set(list.flatMap(o => Object.keys(o.criteria)))]
if (names.length === 0) return true
return nonDiscriminating(list).length === names.length
}
export interface Domination {
dominated: ArchitectureOption
by: ArchitectureOption
}
/**
* Options strictly beaten on requirement coverage by another.
*
* Coverage only — this deliberately does not judge the criteria, because a
* thinner option can be the right answer on cost or schedule. It flags the
* case where an option satisfies a strict subset of another's requirements
* AND adds nothing of its own.
*/
export function dominatedOptions(options: ArchitectureOption[]): Domination[] {
const list = options ?? []
const out: Domination[] = []
for (const a of list) {
for (const b of list) {
if (a.id === b.id) continue
const aSet = new Set(a.satisfies)
const bSet = new Set(b.satisfies)
const aUnique = [...aSet].filter(r => !bSet.has(r))
if (aUnique.length === 0 && bSet.size > aSet.size) {
out.push({ dominated: a, by: b })
break
}
}
}
return out
}
/** Requirements no option satisfies. */
export function uncoveredRequirements(
options: ArchitectureOption[],
requirementIds: string[],
): string[] {
const covered = new Set((options ?? []).flatMap(o => o.satisfies))
return (requirementIds ?? []).filter(id => !covered.has(id))
}
+273
View File
@@ -0,0 +1,273 @@
/**
* MissionSynth — turn a mission concept into defensible requirements,
* discriminated architecture options, and a TRL roadmap capped by evidence.
*
* **What this does and does not do.** It does not write requirements. Turning
* a concept into candidate requirements and architectures is generative
* work, done by people or by a model, and a pure library cannot do it
* honestly. What a library CAN do — and what decides whether the output
* survives a review — is check that the synthesis holds together:
*
* - every requirement traces to a stated need, and every need to a
* requirement (the two failures are opposites, so both directions run)
* - every requirement names a verification method and a criterion someone
* could decide
* - the architecture options actually differ on a stated criterion
* - the TRL claim does not exceed what the evidence demonstrates
*
* That is the same discipline as the rest of this set: AdversarialSuite
* derives cases and runs nothing, SelfServe gates provisioning and provisions
* nothing, and this structures a synthesis it does not perform.
*
* It sits UPSTREAM of `@extant2000/trace-proof`, which takes the requirements
* that survive this and links them to system evidence. The handover is the
* requirement id.
*
* Pure: no clock, no I/O, no model calls.
*/
import {
type CapabilityReport,
type Finding,
type FormatOptions,
evidence,
formatReport as renderReport,
} from '@extant2000/evidence-record'
import {
type Need,
type Requirement,
requirementIssues,
unaddressedNeeds,
} from './requirements.js'
import {
type ArchitectureOption,
dominatedOptions,
isUndiscriminated,
nonDiscriminating,
uncoveredRequirements,
} from './architecture.js'
import {
type MaturityRecord,
type TRL,
TRL_DEFINITION,
assessTrl,
maturationPath,
} from './trl.js'
export * from './requirements.js'
export * from './architecture.js'
export * from './trl.js'
export interface MissionConcept {
name: string
needs: Need[]
requirements: Requirement[]
options?: ArchitectureOption[]
/** What has actually been demonstrated. */
maturity?: MaturityRecord[]
/** The level being claimed today. */
claimedTrl?: TRL
/** The level the programme is aiming for. */
targetTrl?: TRL
}
function requirementFindings(c: MissionConcept): Finding[] {
const issues = requirementIssues(c.requirements ?? [], c.needs ?? [])
const byId = new Map((c.requirements ?? []).map(r => [r.id, r]))
const out: Finding[] = issues.map(i => {
const r = byId.get(i.requirementId)
return {
id: `req/${i.requirementId}/${i.defect}`,
summary: `${i.requirementId}: ${i.defect.replace('-', ' ')}`,
determination: i.defect === 'invented' || i.defect === 'dangling-parent' ? 'fail' : 'fail',
severity: i.defect === 'invented' ? 'critical' : 'high',
detail: i.detail,
evidence: r ? [evidence.file(r.where, undefined, r.statement.slice(0, 100))] : [],
}
})
for (const n of unaddressedNeeds(c.requirements ?? [], c.needs ?? [])) {
out.push({
id: `need/${n.id}`,
summary: `${n.id} is stated and no requirement covers it`,
determination: 'fail',
severity: 'critical',
detail: `"${n.statement}" (${n.source}). The needs that get forgotten are the ones nobody enjoys thinking about, and a downward-only derivation check never sees them.`,
evidence: [evidence.file(n.where, undefined, n.statement.slice(0, 100), `source: ${n.source}`)],
})
}
const clean = (c.requirements ?? []).filter(
r => !issues.some(i => i.requirementId === r.id),
)
for (const r of clean) {
out.push({
id: `req/${r.id}`,
summary: `${r.id} traces to ${r.derivedFrom.join(', ')} and is verifiable by ${r.verification!.method}`,
determination: 'pass',
severity: 'info',
detail: `Criterion: ${r.verification!.criterion}`,
evidence: [evidence.file(r.where, undefined, r.statement.slice(0, 100))],
})
}
return out
}
function architectureFindings(c: MissionConcept): Finding[] {
const options = c.options ?? []
const out: Finding[] = []
if (options.length === 0) return out
if (options.length === 1) {
out.push({
id: 'arch/single-option',
summary: `Only one architecture option was carried: ${options[0]!.name}`,
determination: 'not-assessed',
severity: 'high',
detail: 'A single architecture with a rationale is a decision already made. There is nothing to trade, so no trade study was performed — whatever the document is called.',
evidence: [evidence.file(options[0]!.where, undefined, options[0]!.name)],
})
return out
}
if (isUndiscriminated(options)) {
out.push({
id: 'arch/undiscriminated',
summary: `No criterion tells the ${options.length} options apart`,
determination: 'fail',
severity: 'critical',
detail: 'Every option scores identically on every stated criterion, so the choice between them rests on something not written down. The table is full and the analysis is absent — which is why this survives review.',
evidence: options.map(o => evidence.file(o.where, undefined, o.name)),
})
} else {
const flat = nonDiscriminating(options)
if (flat.length > 0) {
out.push({
id: 'arch/flat-criteria',
summary: `${flat.length} criterion/criteria discriminate nothing: ${flat.join(', ')}`,
determination: 'not-applicable',
severity: 'info',
detail: 'These columns are identical across every option. They are not wrong, they are just not doing any work in the decision.',
evidence: options.map(o => evidence.file(o.where, undefined, o.name)),
})
}
}
for (const d of dominatedOptions(options)) {
out.push({
id: `arch/dominated/${d.dominated.id}`,
summary: `${d.dominated.name} is dominated by ${d.by.name} on requirement coverage`,
determination: 'not-applicable',
severity: 'info',
detail: 'It satisfies a strict subset and adds nothing of its own. It may still win on cost or schedule — but carrying it unexamined makes the study look broader than it is.',
evidence: [evidence.file(d.dominated.where, undefined, d.dominated.name)],
})
}
const uncovered = uncoveredRequirements(options, (c.requirements ?? []).map(r => r.id))
if (uncovered.length > 0) {
out.push({
id: 'arch/uncovered',
summary: `${uncovered.length} requirement(s) satisfied by no option: ${uncovered.join(', ')}`,
determination: 'fail',
severity: 'critical',
detail: 'No architecture on the table meets these. Either an option is missing or the requirement is.',
evidence: options.map(o => evidence.file(o.where, undefined, o.name)),
})
}
return out
}
function trlFindings(c: MissionConcept): Finding[] {
if (c.claimedTrl === undefined) return []
const records = c.maturity ?? []
const a = assessTrl(c.claimedTrl, records)
if (a.supported === null) {
return [{
id: 'trl',
summary: `TRL ${c.claimedTrl} is claimed with no demonstration recorded`,
determination: 'not-assessed',
severity: 'critical',
detail: 'No evidence at all does not establish TRL 1 either — TRL 1 is a claim that basic principles were observed and reported, and that is itself a demonstration.',
evidence: [],
}]
}
const cite = a.strongest
? [evidence.file(a.strongest.where, undefined, a.strongest.description, `evidence: ${a.strongest.evidence}`)]
: []
if (a.capped) {
return [{
id: 'trl',
summary: `TRL ${c.claimedTrl} claimed, evidence supports TRL ${a.supported}`,
determination: 'fail',
severity: 'critical',
detail: `The strongest demonstration is "${a.strongest!.description}", which supports ${a.supported}: ${TRL_DEFINITION[a.supported]}. TRL ${c.claimedTrl} requires ${TRL_DEFINITION[c.claimedTrl]}. The level is not a judgement call — each one names the environment a thing was demonstrated in, and an environment is a fact.`,
evidence: cite,
}]
}
return [{
id: 'trl',
summary: `TRL ${c.claimedTrl} claimed and supported`,
determination: 'pass',
severity: 'info',
detail: TRL_DEFINITION[c.claimedTrl],
evidence: cite,
}]
}
/** Assess a mission concept's requirements, options and maturity claim. */
export function synthesize(c: MissionConcept): CapabilityReport {
const needs = c.needs ?? []
const reqs = c.requirements ?? []
const options = c.options ?? []
if (needs.length === 0 && reqs.length === 0) {
return {
capability: 'MissionSynth',
scope: `${c.name} — nothing supplied`,
examined: 0,
findings: [],
notes: ['No needs and no requirements were supplied. A concept with neither has not been synthesised, only named.'],
}
}
const findings = [
...trlFindings(c),
...requirementFindings(c),
...architectureFindings(c),
]
const a = c.claimedTrl !== undefined ? assessTrl(c.claimedTrl, c.maturity ?? []) : null
const path = c.targetTrl !== undefined
? maturationPath(a?.effective ?? null, c.targetTrl)
: []
return {
capability: 'MissionSynth',
scope: `${c.name} — ${needs.length} need(s), ${reqs.length} requirement(s), ${options.length} option(s)`,
examined: needs.length + reqs.length + options.length,
findings,
notes: [
a
? `TRL: claimed ${a.claimed}, supported ${a.supported ?? 'nothing'}, effective ${a.effective ?? 'unestablished'}`
: 'No TRL claimed.',
...(path.length > 0
? [`maturation to TRL ${c.targetTrl}: ${path.length} step(s) — ${path.map(s => `${s.to}: ${s.produces}`).join(' → ')}`]
: []),
],
}
}
/** Render a synthesis for a terminal, a review, or a concept package. */
export function formatSynthesis(c: MissionConcept, opts: FormatOptions = {}): string {
return renderReport(synthesize(c), opts)
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Requirements derived from needs, checked in BOTH directions.
*
* The two defects a generated requirement set reliably has are opposites,
* and a one-directional check finds only one of them:
*
* - **An invention.** A requirement that traces to no stated need. It reads
* perfectly well, it is plausible, and nobody asked for it. This is the
* characteristic failure of a synthesised set, because generating
* plausible requirements is easy and generating only warranted ones is
* not.
*
* - **An unaddressed need.** A stated need that no requirement covers. This
* is the characteristic failure of a hand-written set, because the needs
* that get forgotten are the ones nobody enjoys thinking about.
*
* Checking derivation only downward finds inventions and misses gaps;
* checking coverage only upward finds gaps and misses inventions. Both run.
*
* The verifiability check is separate and older than any of this: a
* requirement that names no verification method and no measurable criterion
* is a wish. "The system shall be user-friendly" cannot be shown false, so
* it cannot be shown true.
*/
export interface Need {
id: string
statement: string
/** Who or what it comes from — a stakeholder, a standard, a constraint. */
source: string
where: string
}
export type VerificationMethod = 'test' | 'analysis' | 'inspection' | 'demonstration'
export interface Verification {
method: VerificationMethod
/**
* The acceptance criterion. Must be decidable — a number, a threshold, a
* pass condition. "Acceptable performance" is not a criterion.
*/
criterion: string
}
export interface Requirement {
id: string
statement: string
/** Need ids this was derived from. Empty means it was invented. */
derivedFrom: string[]
verification?: Verification
where: string
}
export type RequirementDefect =
| 'invented'
| 'dangling-parent'
| 'unverifiable'
| 'immeasurable'
export interface RequirementIssue {
requirementId: string
defect: RequirementDefect
detail: string
}
/**
* Words that describe a feeling rather than a threshold.
*
* Deliberately short and deliberately not exhaustive. This is a smell test
* that catches the common cases, not a grammar — a criterion can be
* unmeasurable without using any of these words, which is why the absence of
* a hit is not evidence of a good criterion.
*/
const VAGUE = [
'user-friendly', 'user friendly', 'easy to use', 'intuitive', 'robust',
'reliable', 'fast', 'efficient', 'as needed', 'as appropriate',
'sufficient', 'adequate', 'acceptable', 'reasonable', 'minimal',
'state of the art', 'best effort', 'if possible', 'etc',
]
/** True when the text names something that could be measured or observed. */
export function isMeasurable(text: string): boolean {
const lower = text.toLowerCase()
if (VAGUE.some(v => lower.includes(v))) return false
// A decidable criterion almost always carries a number, a comparison, or
// an explicit pass condition.
return /\d/.test(lower)
|| /\b(shall|must)\s+(not\s+)?(equal|exceed|match|contain|return|reject|be)\b/.test(lower)
|| /\b(pass|fail|present|absent|within|between|less than|greater than|at least|at most)\b/.test(lower)
}
/** Requirements that are unwarranted, unverifiable, or both. */
export function requirementIssues(
requirements: Requirement[],
needs: Need[],
): RequirementIssue[] {
const needIds = new Set((needs ?? []).map(n => n.id))
const out: RequirementIssue[] = []
for (const r of requirements ?? []) {
if (r.derivedFrom.length === 0) {
out.push({
requirementId: r.id,
defect: 'invented',
detail: 'Traces to no stated need. It may be a good idea; it is not a derived requirement, and nothing in the concept asked for it.',
})
} else {
const dangling = r.derivedFrom.filter(id => !needIds.has(id))
if (dangling.length > 0) {
out.push({
requirementId: r.id,
defect: 'dangling-parent',
detail: `Derived from ${dangling.join(', ')}, which ${dangling.length === 1 ? 'is not a' : 'are not'} stated need${dangling.length === 1 ? '' : 's'}. A parent that does not exist is the same as no parent, and it looks like traceability.`,
})
}
}
if (!r.verification) {
out.push({
requirementId: r.id,
defect: 'unverifiable',
detail: 'No verification method. A requirement nobody can show to be met is a wish — and it will pass every review, because there is nothing to fail.',
})
} else if (!isMeasurable(r.verification.criterion)) {
out.push({
requirementId: r.id,
defect: 'immeasurable',
detail: `Acceptance criterion "${r.verification.criterion}" cannot be decided. Name a threshold, a count, or an explicit pass condition.`,
})
}
}
return out
}
/** Needs no requirement covers. */
export function unaddressedNeeds(requirements: Requirement[], needs: Need[]): Need[] {
const covered = new Set((requirements ?? []).flatMap(r => r.derivedFrom))
return (needs ?? []).filter(n => !covered.has(n.id))
}
+181
View File
@@ -0,0 +1,181 @@
/**
* TRL, capped by evidence rather than asserted.
*
* Technology Readiness Level is the most self-assessed number in the
* business. The gap between "we are at TRL 6" and "here is what would
* demonstrate TRL 6" is where programmes quietly go wrong, and it goes wrong
* in one direction only — nobody ever claims a level below the one they can
* show.
*
* The definitions are not vague, and that is what makes this checkable. Each
* level names the ENVIRONMENT in which something was demonstrated, and an
* environment is a fact:
*
* TRL 4 component validated in a LABORATORY
* TRL 5 component validated in a RELEVANT environment
* TRL 6 system model demonstrated in a RELEVANT environment
* TRL 7 system prototype in an OPERATIONAL environment
*
* So a claim of TRL 6 backed by a passing unit test is not a judgement call
* that reasonable people might differ on. A unit test is a laboratory
* activity on a component, and it supports TRL 4.
*
* Same discipline as `@extant2000/sustain-score`: cap by the weakest evidence,
* never average, and report the cap rather than silently applying it.
*/
export type TRL = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
/** What was actually done, as opposed to what level was claimed. */
export type MaturityEvidence =
/** Principles observed and reported. Paper. */
| 'concept-paper'
/** Technology concept formulated — analysis, no experiment. */
| 'analysis'
/** Analytical and experimental proof of concept. A bench or a unit test. */
| 'lab-bench'
/** Component validated in a laboratory. */
| 'component-lab'
/** Component validated in a RELEVANT environment. */
| 'component-relevant-env'
/** System or subsystem model demonstrated in a RELEVANT environment. */
| 'system-relevant-env'
/** System prototype demonstrated in an OPERATIONAL environment. */
| 'prototype-operational-env'
/** Actual system completed and qualified through test and demonstration. */
| 'qualified'
/** Actual system proven through successful mission operations. */
| 'operational-mission'
/**
* The highest TRL each kind of evidence can support.
*
* Read this as a ceiling, not a score. Evidence at `component-lab` does not
* mean you ARE at TRL 4; it means you cannot be above it on this evidence.
*/
export const EVIDENCE_CEILING: Record<MaturityEvidence, TRL> = {
'concept-paper': 1,
analysis: 2,
'lab-bench': 3,
'component-lab': 4,
'component-relevant-env': 5,
'system-relevant-env': 6,
'prototype-operational-env': 7,
qualified: 8,
'operational-mission': 9,
}
/** Plain-language description of what each level actually requires. */
export const TRL_DEFINITION: Record<TRL, string> = {
1: 'basic principles observed and reported',
2: 'technology concept formulated',
3: 'analytical and experimental proof of concept',
4: 'component validated in a laboratory',
5: 'component validated in a relevant environment',
6: 'system or subsystem model demonstrated in a relevant environment',
7: 'system prototype demonstrated in an operational environment',
8: 'actual system completed and qualified through test and demonstration',
9: 'actual system proven through successful mission operations',
}
export interface MaturityRecord {
/** What kind of demonstration this was. */
evidence: MaturityEvidence
/** One line on what was actually done. */
description: string
/** Where it is recorded — a report, a test id, a campaign. */
where: string
}
export interface TrlAssessment {
claimed: TRL
/** The highest level the supplied evidence supports. */
supported: TRL | null
/** `supported` when it is lower than `claimed`, otherwise `claimed`. */
effective: TRL | null
/** True when the claim ran ahead of the evidence. */
capped: boolean
/** The strongest record, which is the one that set the ceiling. */
strongest?: MaturityRecord
}
/**
* The highest level the evidence supports.
*
* The BEST evidence sets the ceiling here, not the worst — TRL is a
* high-water mark of demonstration, and having also written a paper does not
* drag down a system demonstrated in a relevant environment. The
* anti-inflation move is elsewhere: the claim is capped to this, and no
* amount of claiming raises it.
*
* Returns `null` for no evidence. Not TRL 1 — TRL 1 is a claim that
* principles were observed and reported, and no evidence at all does not
* establish even that.
*/
export function supportedTrl(records: MaturityRecord[]): TRL | null {
const list = records ?? []
if (list.length === 0) return null
return list.reduce<TRL>((hi, r) => {
const c = EVIDENCE_CEILING[r.evidence]
return c > hi ? c : hi
}, 1 as TRL)
}
/** Assess a claimed TRL against what was actually demonstrated. */
export function assessTrl(claimed: TRL, records: MaturityRecord[]): TrlAssessment {
const list = records ?? []
const supported = supportedTrl(list)
if (supported === null) {
return { claimed, supported: null, effective: null, capped: false }
}
const strongest = [...list].sort(
(a, b) => EVIDENCE_CEILING[b.evidence] - EVIDENCE_CEILING[a.evidence],
)[0]
return {
claimed,
supported,
effective: Math.min(claimed, supported) as TRL,
capped: supported < claimed,
strongest,
}
}
export interface MaturationStep {
from: TRL
to: TRL
/** What has to be done. */
activity: string
/** The evidence that activity produces — this is what raises the level. */
produces: MaturityEvidence
}
/**
* The steps between where the evidence puts you and where you want to be.
*
* Generated from the level definitions rather than invented, so each step
* names the demonstration that actually moves the number. A roadmap whose
* steps do not produce evidence is a schedule, not a maturation plan.
*/
export function maturationPath(from: TRL | null, to: TRL): MaturationStep[] {
const start = from ?? 0
if (to <= start) return []
const byLevel = new Map<TRL, MaturityEvidence>()
for (const [ev, lvl] of Object.entries(EVIDENCE_CEILING) as [MaturityEvidence, TRL][]) {
byLevel.set(lvl, ev)
}
const steps: MaturationStep[] = []
for (let lvl = (start + 1) as TRL; lvl <= to; lvl = (lvl + 1) as TRL) {
steps.push({
from: Math.max(start, lvl - 1) as TRL,
to: lvl,
activity: `Demonstrate: ${TRL_DEFINITION[lvl]}`,
produces: byLevel.get(lvl)!,
})
}
return steps
}
+267
View File
@@ -0,0 +1,267 @@
import { describe, expect, it } from 'vitest'
import { overall, validateReport } from '@extant2000/evidence-record'
import {
assessTrl,
dominatedOptions,
formatSynthesis,
isMeasurable,
isUndiscriminated,
maturationPath,
nonDiscriminating,
requirementIssues,
supportedTrl,
synthesize,
unaddressedNeeds,
type MissionConcept,
type Need,
type Requirement,
} from '../src/index.js'
const need = (p: Partial<Need> = {}): Need => ({
id: 'N-1',
statement: 'the spacecraft must deorbit within 25 years of end of mission',
source: 'ODMSP 2019',
where: 'concept/needs.md',
...p,
})
const req = (p: Partial<Requirement> = {}): Requirement => ({
id: 'SYS-001',
statement: 'The system shall deorbit within 25 years',
derivedFrom: ['N-1'],
verification: { method: 'analysis', criterion: 'orbital decay analysis shows re-entry at less than 25 years' },
where: 'concept/requirements.md',
...p,
})
describe('derivation is checked in both directions', () => {
it('flags an invented requirement — the failure of a synthesised set', () => {
// Generating plausible requirements is easy; generating only warranted
// ones is not.
const issues = requirementIssues([req({ derivedFrom: [] })], [need()])
expect(issues.some(i => i.defect === 'invented')).toBe(true)
expect(issues.find(i => i.defect === 'invented')!.detail)
.toContain('nothing in the concept asked for it')
})
it('flags an unaddressed need — the failure of a hand-written set', () => {
// A downward-only check never sees this.
const orphanNeed = need({ id: 'N-2', statement: 'must survive launch loads' })
expect(unaddressedNeeds([req()], [need(), orphanNeed]).map(n => n.id)).toEqual(['N-2'])
})
it('rates an invented requirement critical and reports both directions together', () => {
const c: MissionConcept = {
name: 'test',
needs: [need(), need({ id: 'N-2', statement: 'survive launch' })],
requirements: [req(), req({ id: 'SYS-009', derivedFrom: [] })],
}
const r = synthesize(c)
expect(r.findings.some(f => f.id === 'req/SYS-009/invented')).toBe(true)
expect(r.findings.some(f => f.id === 'need/N-2')).toBe(true)
expect(overall(r)).toBe('fail')
})
it('catches a parent that does not exist — traceability that looks real', () => {
const issues = requirementIssues([req({ derivedFrom: ['N-99'] })], [need()])
const d = issues.find(i => i.defect === 'dangling-parent')!
expect(d.detail).toContain('it looks like traceability')
})
})
describe('a requirement nobody can fail is not a requirement', () => {
it('flags a missing verification method', () => {
const issues = requirementIssues([req({ verification: undefined })], [need()])
const u = issues.find(i => i.defect === 'unverifiable')!
expect(u.detail).toContain('there is nothing to fail')
})
it('rejects a criterion that describes a feeling', () => {
expect(isMeasurable('the interface shall be user-friendly')).toBe(false)
expect(isMeasurable('performance shall be acceptable')).toBe(false)
expect(isMeasurable('response time shall be less than 200 ms')).toBe(true)
expect(isMeasurable('the request shall be rejected')).toBe(true)
})
it('flags an immeasurable criterion', () => {
const issues = requirementIssues(
[req({ verification: { method: 'inspection', criterion: 'the design is robust' } })],
[need()],
)
expect(issues.some(i => i.defect === 'immeasurable')).toBe(true)
})
it('passes a properly derived, verifiable requirement', () => {
expect(requirementIssues([req()], [need()])).toEqual([])
expect(overall(synthesize({ name: 't', needs: [need()], requirements: [req()] }))).toBe('pass')
})
})
describe('TRL is capped by evidence, never asserted', () => {
it('REPRODUCES THE CLASSIC OVERCLAIM: TRL 6 backed by a unit test', () => {
// A unit test is a laboratory activity on a component. It supports 4.
const a = assessTrl(6, [
{ evidence: 'lab-bench', description: 'unit test suite passes', where: 'ci/unit-tests' },
])
expect(a.supported).toBe(3)
expect(a.effective).toBe(3)
expect(a.capped).toBe(true)
})
it('explains the cap by naming the environment each level requires', () => {
const c: MissionConcept = {
name: 'deorbit module', needs: [need()], requirements: [req()],
claimedTrl: 6,
maturity: [{ evidence: 'component-lab', description: 'thruster fired on the bench', where: 'test/TR-12' }],
}
const f = synthesize(c).findings.find(x => x.id === 'trl')!
expect(f.determination).toBe('fail')
expect(f.detail).toContain('an environment is a fact')
expect(f.detail).toContain('system or subsystem model demonstrated in a relevant environment')
})
it('takes the BEST evidence as the ceiling, not the worst', () => {
// Having also written a paper does not drag down a system demonstration.
expect(supportedTrl([
{ evidence: 'concept-paper', description: 'white paper', where: 'a' },
{ evidence: 'system-relevant-env', description: 'thermal vac campaign', where: 'b' },
])).toBe(6)
})
it('returns null for no evidence — not TRL 1', () => {
// TRL 1 is itself a claim that principles were observed and reported.
expect(supportedTrl([])).toBeNull()
const f = synthesize({
name: 't', needs: [need()], requirements: [req()], claimedTrl: 4,
}).findings.find(x => x.id === 'trl')!
expect(f.determination).toBe('not-assessed')
expect(f.detail).toContain('does not establish TRL 1 either')
})
it('accepts a claim the evidence supports', () => {
const a = assessTrl(5, [
{ evidence: 'component-relevant-env', description: 'vibe test at flight levels', where: 'TR-30' },
])
expect(a.capped).toBe(false)
expect(a.effective).toBe(5)
})
it('does not raise a modest claim to the evidence ceiling', () => {
const a = assessTrl(3, [{ evidence: 'qualified', description: 'full qual', where: 'x' }])
expect(a.effective).toBe(3)
expect(a.capped).toBe(false)
})
})
describe('the maturation path names demonstrations, not dates', () => {
it('produces one step per level, each with the evidence that raises it', () => {
const path = maturationPath(4, 6)
expect(path.map(s => s.to)).toEqual([5, 6])
expect(path[0]!.produces).toBe('component-relevant-env')
expect(path[1]!.produces).toBe('system-relevant-env')
expect(path[1]!.activity).toContain('relevant environment')
})
it('starts from zero when nothing has been demonstrated', () => {
expect(maturationPath(null, 3).map(s => s.to)).toEqual([1, 2, 3])
})
it('is empty when the target is already met', () => {
expect(maturationPath(7, 6)).toEqual([])
})
it('is reported from the EFFECTIVE level, not the claimed one', () => {
const text = formatSynthesis({
name: 't', needs: [need()], requirements: [req()],
claimedTrl: 6, targetTrl: 7,
maturity: [{ evidence: 'lab-bench', description: 'unit tests', where: 'ci' }],
})
// effective is 3, so the path is 4..7 — four steps, not one.
expect(text).toContain('4 step(s)')
})
})
describe('a trade study with nothing to trade', () => {
const opt = (id: string, criteria: Record<string, string | number>, satisfies: string[] = ['SYS-001']) =>
({ id, name: `option ${id}`, satisfies, criteria, where: `trade/${id}.md` })
it('flags a single option as no trade study at all', () => {
const f = synthesize({
name: 't', needs: [need()], requirements: [req()],
options: [opt('A', { cost: 1 })],
}).findings.find(x => x.id === 'arch/single-option')!
expect(f.determination).toBe('not-assessed')
expect(f.detail).toContain('a decision already made')
})
it('CATCHES THE THEATRE: options identical on every criterion', () => {
// The table is full and the analysis is absent.
const options = [opt('A', { cost: 3, mass: 10 }), opt('B', { cost: 3, mass: 10 })]
expect(isUndiscriminated(options)).toBe(true)
const f = synthesize({ name: 't', needs: [need()], requirements: [req()], options })
.findings.find(x => x.id === 'arch/undiscriminated')!
expect(f.determination).toBe('fail')
expect(f.detail).toContain('which is why this survives review')
})
it('names the columns doing no work when only some are flat', () => {
const options = [opt('A', { cost: 3, mass: 10 }), opt('B', { cost: 5, mass: 10 })]
expect(nonDiscriminating(options)).toEqual(['mass'])
expect(isUndiscriminated(options)).toBe(false)
})
it('flags an option dominated on coverage', () => {
const options = [
opt('A', { cost: 1 }, ['SYS-001']),
opt('B', { cost: 2 }, ['SYS-001', 'SYS-002']),
]
const d = dominatedOptions(options)
expect(d).toHaveLength(1)
expect(d[0]!.dominated.id).toBe('A')
})
it('does not call an option dominated when it covers something unique', () => {
const options = [
opt('A', { cost: 1 }, ['SYS-003']),
opt('B', { cost: 2 }, ['SYS-001', 'SYS-002']),
]
expect(dominatedOptions(options)).toEqual([])
})
it('fails when no option satisfies a requirement', () => {
const f = synthesize({
name: 't', needs: [need()], requirements: [req(), req({ id: 'SYS-002' })],
options: [opt('A', { cost: 1 }, ['SYS-001']), opt('B', { cost: 2 }, ['SYS-001'])],
}).findings.find(x => x.id === 'arch/uncovered')!
expect(f.determination).toBe('fail')
expect(f.summary).toContain('SYS-002')
})
})
describe('conformance with the evidence-record standard', () => {
it('an empty concept is not-assessed, and says what empty means', () => {
const r = synthesize({ name: 'unnamed', needs: [], requirements: [] })
expect(overall(r)).toBe('not-assessed')
expect(formatSynthesis({ name: 'unnamed', needs: [], requirements: [] }))
.toContain('has not been synthesised, only named')
})
it('every conclusion carries a citation', () => {
expect(validateReport(synthesize({
name: 't',
needs: [need(), need({ id: 'N-2' })],
requirements: [req(), req({ id: 'SYS-009', derivedFrom: [] })],
claimedTrl: 6,
maturity: [{ evidence: 'lab-bench', description: 'unit tests', where: 'ci' }],
options: [
{ id: 'A', name: 'A', satisfies: ['SYS-001'], criteria: { cost: 1 }, where: 'a.md' },
{ id: 'B', name: 'B', satisfies: ['SYS-001'], criteria: { cost: 2 }, where: 'b.md' },
],
}))).toEqual([])
})
it('hands off to TraceProof on the requirement id', () => {
const r = synthesize({ name: 't', needs: [need()], requirements: [req()] })
expect(r.findings.some(f => f.id === 'req/SYS-001')).toBe(true)
})
})
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"outDir": "dist-cjs",
"declaration": false
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"target": "ES2022", "module": "ES2022", "moduleResolution": "bundler",
"declaration": true, "outDir": "dist", "rootDir": "src",
"strict": true, "skipLibCheck": true
},
"include": ["src/**/*.ts"]
}