First public release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env*
|
||||
!.env.example
|
||||
.npmrc
|
||||
dist-cjs/
|
||||
@@ -0,0 +1,6 @@
|
||||
stages: [test]
|
||||
|
||||
test:
|
||||
stage: test
|
||||
image: node:22-alpine
|
||||
script: [npm ci, npm run build, npm test]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
Generated
+1503
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"outDir": "dist-cjs",
|
||||
"declaration": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022", "module": "ES2022", "moduleResolution": "bundler",
|
||||
"declaration": true, "outDir": "dist", "rootDir": "src",
|
||||
"strict": true, "skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user