Search by

rmb32 / concept-manager

rogerbarnfather

Concepts, kinds and the requirements between them

Package info

bitbucket.org/rmb32/concept-manager/

Homepage

pkg:composer/rmb32/concept-manager

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

v2.0.0 2026-10-24 11:36 UTC

This package is auto-updated.

Last update: 2026-10-24 11:37:14 UTC


README

Concept Manager holds concepts, the kinds that describe them fully, and the requirements between them. A named place (the bed in a bedroom) is kept apart from whatever fills it, so one kind can fill many places by reference, and everything below a concept keeps what is true of it.

What it needs

PHP 8.5, the ds extension (v2), and rmb32/typed-json, which reads files and brings cuyz/valinor with it. Composer installs both.

Installation

composer require rmb32/concept-manager

2.0 changes Instance, which 1.x laid out only from a kind: Instance::of() now takes a concept too, and lays out its outline, so kind() may be null. main is aliased 2.x-dev.

The words

  • Headspace: the concepts and kinds about one subject, built whole from declarations. It never changes: an edit is a rebuild.
  • Concept: an idea that may still ask for things. It can extend one other concept.
  • Kind: a description of a concept with nothing left open. A kind is never an individual: the same kind at two places is two instances of one description, not one thing in two places. Nothing extends a kind.
  • Requirement: a named place a concept asks to have filled, by one kind of some concept or by many. It is unmet until kinds are given, and met afterwards.
  • Range: how many kinds a requirement takes. A requirement for one takes exactly one. A requirement for many takes any number unless it says otherwise: at least some (atLeast), at most some (atMost), and perhaps no kind twice (distinct).
  • Part: a requirement for one that is always there, held by a concept. It is open while its concept leaves anything unmet, and met once everything in it is, by a kind of that concept with the part's name and meaning. A part can hold parts of its own.
  • Meaning: what a headspace, concept, kind or requirement is about, in words. Each has a name and a meaning. Names are free text (Living room).
  • requires: adds an unmet requirement.
  • provides: adds a requirement that is met from the start, or a part.
  • resolves: meets an inherited requirement.
  • within: meets and narrows requirements inside an inherited part, in place.
  • narrows: makes an inherited unmet requirement ask for a concept that is or extends the one it asked for, tightens the range of a requirement for many, or both.
  • Inline kind: a kind declared in place, wherever a declaration names a kind. It has its own name and meaning but is not registered: its name needn't be unique, and nothing else can use it.
  • Inline concept: a concept declared in place, wherever a declaration names a concept. Like an inline kind it is not registered.
  • Kind that fills: an inline kind of whatever concept the requirement it meets asks for, so it names no concept itself.
  • Local: a concept or kind that only the top-level declaration holding it can name, written once and used wherever that declaration needs it.
  • Metadata: what is said of a concept, kind or requirement that Concept Manager carries and never reads: keys, each with a bool, int, float or string.
  • Instance: one occurrence of a kind at one place in a tree of kinds, or, in an outline, of the concept itself or of a part it leaves open.
  • Outline: a concept laid out before anything is chosen: every part, open or met, and every met requirement's kinds.
  • Open place: a requirement an outline leaves unmet. Nothing is laid out beneath it, since that depends on what fills it.
  • Entry point: a kind no declaration in the headspace names, where reading it starts.
  • Supplied metadata: metadata given from outside a headspace at a path in a tree, for whoever reads the tree. Concept Manager reads none of it.
  • Declaration file: one top-level declaration, written as JSON, with the headspace it belongs to and perhaps a group.
  • Group: a heading within a headspace that gathers related declarations. It changes nothing about what is declared.
  • Source: a folder of declaration files, with a headspaces.json saying which headspaces and groups it describes. A shipped source is read-only; your own can be written to.
  • Registry: declarations over an ordered list of sources, in which nothing shadows anything.
  • Status: how a declaration stands once its headspace is built: faulty, open at the places its outline leaves open, or complete.
  • Impact: what a change would do to the statuses of the declarations it reaches, found before it is written.
  • Projection: a tree or an outline turned into a file, or a folder of files, for whoever will read it: a tree of names, a document, folders or a diagram.

The principle

Whatever is true of a concept stays true of everything that extends it and of every kind of it, and a kind leaves nothing open.

The rules

  1. Every name in a headspace is unique across concepts and kinds; a later declaration of a taken name is a fault. Declarations may come in any order. No kind may hold itself and no concept may extend itself, directly or through others; a concept may ask for itself, directly or through others. Inside a top-level declaration, which includes what is written inside it and its locals, a name means one of its locals first and the headspace's after. Its locals' names are unique among them and differ from its own.
  2. A concept inherits every requirement and all the metadata of the concept it extends, and a kind all the metadata of its concept. Either may add a metadata key and never changes one.
  3. A requirement name is unique within its lineage and within one declaration. resolves and narrows name an inherited requirement, and within an inherited part.
  4. A requirement is met only by kinds of the concept it asks for, or of a concept extending it. It is narrowed only to a concept that is or extends the one it asks for, and its range only tightened: no fewer at least, no more at most, and no kind twice once it says so. Only a requirement for many has a range to tighten.
  5. A requirement for one is met by exactly one kind. A requirement for many is met by a list as long as its range allows; unless it says otherwise, the list may be empty or repeat a kind.
  6. Once met, a requirement stays met.
  7. A kind leaves nothing unmet, including what it inherits from every ancestor and anything inside a part.
  8. Every concept, kind, requirement and headspace has a name and a meaning, and neither is blank.
  9. Every concept can be met: it, or a concept that extends it, can have a kind. A concept that asks for one of itself, directly or through others, can never be met, since each kind of it would need another without end. A requirement for many ends wherever its range allows an empty list, and a requirement whose range takes nothing (atLeast: 2, atMost: 1) can never be met.
  10. Every local is named by the declaration that holds it, directly or through its other locals.

Where each rule comes from

  • Unique names, local names and rule 3: bookkeeping, since a name picks out one thing. A local is a name its holder keeps to itself, so it may be one the headspace also has.
  • A loop must pass through asking: a loop of extends, kinds, resolves and provides alone never ends or makes a concept its own ancestor; asking only describes. A folder that holds folders is sound, and so is a kind of it: it names the folders inside it, and they end. Holding is not asking: a part is always there, so a concept that holds itself as a part, directly or through others, never ends.
  • Rules 2, 4, 5 and 6: the principle. A concept's requirements, what each asks for, how many it takes, what met it and its metadata all stay true below it. A range that widened below would let through what the concept above refused. Metadata is true of what carries it, so a key set on a concept is set on everything below it. A requirement's metadata is the place's, and the place keeps it whatever meets, narrows or counts it.
  • No kind twice is something a requirement says, not a rule. A kind is a description, so four legs of one Leg kind are four instances, and a list may repeat a kind unless its requirement says otherwise.
  • Rule 7: what a kind is.
  • Rules 8, 9 and 10: policies, the only three. Rule 9 does not follow from the principle: a concept nobody can make a kind of contradicts nothing. It reports the mistake once, at the concept, rather than at every kind someone tries to declare of it. Rule 10 reports a local that is never used, as Barncept refuses a thought its root does not reach.

Breaking a rule gives a Rmb32\ConceptManager\Concept\Exception\ConceptManagerException that names it: NameTaken, UnknownConcept, UnknownKind, RequirementNameTaken, NoSuchRequirement, KindDoesNotFit, ConceptDoesNotFit, WrongNumberOfKinds, RepeatsAKind, WidensACount, NotARequirementForMany, NegativeCount, RequirementAlreadyMet, RequirementsLeftUnmet, NotAPart, FillsNothing, BlankName, BlankMeaning, CanNeverBeMet, DeclaredInALoop, DependsOnAFault, LocalNeverNamed, MetadataChanged, NotTopLevel or BlankKey. A fault at a requirement inside a part names it by its path from the declaration, as in Ground floor/Kitchen.

Declaring

A declaration is plain data that refers to other things by name:

ConceptDeclaration::new($name, $meaning, $requires, $provides, $locals, $metadata)
ConceptDeclaration::extending($name, $meaning, $parent, $resolves, $narrows, $requires, $provides, $locals, $metadata)
KindDeclaration::of($name, $meaning, $concept, $resolves, $provides, $locals, $metadata)
KindDeclaration::standalone($name, $meaning, $provides, $locals, $metadata)
KindDeclaration::filling($name, $meaning, $resolves, $provides, $metadata)
Requires::one($name, $meaning, $concept, $metadata)
Requires::many($name, $meaning, $concept, $atLeast = 0, $atMost = null, $distinct = false, $metadata)
Provides::one($name, $meaning, $kind, $metadata)
Provides::many($name, $meaning, $kinds, $metadata)
Provides::part($name, $meaning, $requires, $provides, $metadata)
Provides::partOf($name, $meaning, $concept, $metadata)
Resolves::to($requirement, $kindOrKinds)
Narrows::to($requirement, $concept = null, $atLeast = null, $atMost = null, $distinct = null)
Resolves::within($part, $resolves, $narrows)

resolves, narrows, requires and provides are each a list of these records, never an array keyed by name, so a requirement may be named with digits only (12). Every factory takes them in the order an extension applies them: resolves, narrows, requires, provides. A requirement named twice in one declaration is never lost: a second Resolves finds it met (RequirementAlreadyMet), and a second Narrows applies on top of the first, so it must fit it.

A requirement says whether it takes one kind or many. In Provides and Resolves, a single kind or a list gives the kinds in order, and the requirement decides how many it takes: resolving a requirement for many with a single kind gives it a list of one.

A requirement for many may bound what it takes: Requires::many('Garage', …, atMost: 1) is "at most one garage", and atLeast: 1, atMost: 7 is one to seven. distinct: true takes no kind twice (RepeatsAKind). A list outside the range is WrongNumberOfKinds, which names the range. A Narrows may tighten a range, with or without a concept, and whatever it does not give stays as it is: Narrows::to('Aggregates', atMost: 1). A narrowing that would widen a range, or take back distinct, is WidensACount. A count on a requirement for one is NotARequirementForMany, and a negative bound throws NegativeCount when it is written.

Wherever a kind is named, a KindDeclaration may stand in its place as an inline kind. A standalone kind has a concept of its own, of the same name and meaning, that nothing else can ask for.

Wherever a concept is named, a ConceptDeclaration may stand in its place as an inline concept: the concept a requirement asks for, the one a narrowing narrows to, and the one a part is held by. Nothing else can name it, and a fault inside it is the fault of the declaration that holds it. A kind that fills (KindDeclaration::filling) is how a kind of an inline concept is written; it may stand only in a Resolves::to, and anywhere else, provides or the headspace itself, it gets FillsNothing.

Provides::part adds a part with a concept of its own, of the same name and meaning, as a standalone kind has; Provides::partOf adds a part held by a named or inline concept. A part nothing is left open in is met at once. Resolves::within meets and narrows what an inherited part holds, and a within among its resolves reaches a part within that part. In a concept, whatever it leaves open stays open in the part; in a kind, everything in every part must end up met. The part then holds an unregistered extension of its concept, of the part's name and meaning, until nothing in it is open. A part can also be met whole, with Resolves::to and a kind of its concept, or narrowed. Naming anything but a part in a within is NotAPart. An extension cannot add a requirement inside an inherited part.

locals: holds concepts and kinds that only the declaration holding them can name, as a list of declarations. A thought wanted under several parents is written once as a local and named at each place, as Library's Exceptions is below. Inside the holder, a local's name hides the headspace's. Only a declaration passed to the headspace holds locals: one written inside another, or a local, that holds any throws NotTopLevel where it is written. Two locals of one name, or a local named like its holder, is NameTaken, and a local that neither the holder nor its other locals name is LocalNeverNamed.

metadata: comes last everywhere, as an array from a key to a bool, int, float or string, which Concept Manager never reads; a blank key throws BlankKey where it is written. A declaration's metadata is its concept's or kind's, and an extension or a kind of a concept inherits it and may add keys. Changing one is MetadataChanged, which names the key, both values, and where each was set. On Requires and Provides it is the place's: a part's metadata is the requirement's, not its concept's, and the requirement keeps it when it is met, narrowed, counted or met within. Resolves and Narrows carry none.

Headspace::build($name, $meaning, ...$declarations) builds every declaration it can, in an order that puts each after the things it is built from: the concept it extends, the concept of a kind, every kind it names and every concept a part is held by. A concept it only asks for, through requires or narrows, may come later or be itself; a declaration's asksFor() lists those names, and references() every name it uses, including those its inline declarations use. A kind that fills, or a narrowed part, needs a concept its declaration does not name; the headspace builds that concept first, and a declaration that needs itself that way is DeclaredInALoop. A declaration's references() and asksFor() include what its locals use outside it, so the headspace orders a declaration and its locals as one; its locals are built with it, each after what it is built from, and it waits for whatever they are built from. Then concept($name), kind($name) and kindsOf($conceptName) read it. concepts() and kinds() list the concepts and kinds it holds by name, and kindsOf() the names of a concept's kinds, all in the order they were declared. Inline kinds, the concepts of standalone kinds, locals and declarations with a fault are not listed.

The model underneath (Concept, Kind, UnmetRequirement, MetRequirement) can be used directly; each factory takes a name and a meaning first and throws when a rule is broken. Resolution::of($requirement, ...$kinds), Resolution::within($part, $resolves, $narrows) and Narrowing::of($requirement, $concept, $atLeast, $atMost, $distinct) are its Resolves and Narrows, UnmetRequirement::many($name, $meaning, $concept, $range) takes a Range, and Concept::extending and Kind::of take them in the same order as the declarations do. UnmetRequirement::part($name, $meaning, $concept) makes a part, open or met, which provides takes beside met requirements, and Filling::of($name, $meaning, $resolves, $provides) is a kind that fills, which a Resolution takes beside kinds. Every requirement says whether it isPart(). Metadata::of($values) is metadata, with has($key), get($key) and keys(); Concept::new, Concept::extending, Kind::of, Kind::standalone and Filling::of take it last, and a requirement takes it with carrying($metadata). Each of them has its metadata(), a concept's and a kind's including what they inherit.

Faults

Building a headspace never stops at the first problem. Each declaration that cannot be built gives one fault, faults(), holding the declaration's name and its ConceptManagerException, in the order the declarations were given. Everything else is built.

  • A later declaration of a taken name is dropped with NameTaken.
  • Every declaration in a loop that does not pass through asking gets DeclaredInALoop, naming the whole loop.
  • A concept that can never be met gets CanNeverBeMet, naming its requirements that can never be met: those that take at least one kind of a concept that can never be met, and those whose range takes nothing (Concept Folder can never be met: Parent).
  • A declaration that refers to one with a fault is not built, or is taken out of the headspace once the fault is found: it gets DependsOnAFault. A declaration that only asks for one with a fault keeps a fault of its own, if it has one.
  • A fault inside an inline kind, an inline concept or a local is the fault of the declaration that holds it, and carries its own exception (RequirementsLeftUnmet names the inline kind). A loop among a declaration's locals is DeclaredInALoop on the declaration, naming the locals, and a local that can never be met makes its holder CanNeverBeMet, naming the local.
  • A fault at a requirement inside a part, or inside a kind that fills, names the requirement by its path from the declaration: RequirementsLeftUnmet on 12 Orchard Lane names Ground floor/Kitchen, and NoSuchRequirement inside the second of two kinds that fill Courses names Courses[1]/Garnish.
  • A blank headspace name or meaning throws, since there is no declaration to pin it to.
  • Looking up a name whose declaration has a fault, with concept(), kind() or kindsOf(), throws HasAFault, which carries the Fault (Hall has a fault: No concept is named Room). A name never declared gives UnknownConcept or UnknownKind. A DependsOnAFault names what it depends on, so looking that up in turn follows a chain of faults to its root.

Instances and outlines

Instance::of($kind) lays a kind out as a tree, and Instance::of($concept) lays a concept out as its outline. Every part is laid out, open or met, and every met requirement's kinds, one instance for each kind at each place it fills. An instance's children are its parts, then its met requirements' kinds, each in requirement order and then list order. A requirement left unmet is not laid out: it is an open place, listed by open() on the instance it belongs to, with its requirement(), path() and metadata(). Every instance in an outline has a kind except the root and the parts it leaves open; a kind's tree has no open places.

An instance knows its kind(), its concept(), the place() it is at (a part, open or met, or the met requirement it fills), its position() in a requirement for many (from 0) and its path(): the requirement names from the root, such as Contexts[1]/Domain. Paths are compared step by step and never parsed, so a name may hold any character; the text form is only for reading. Its name() and meaning() are the part's at a part, its kind's where it fills a requirement, and the concept's or kind's it lays out at the root, so a fixed place can be told from one a kind was chosen for.

descendants() lists every instance below one, depth first, each before its children. openPlaces() lists every open place in it and beneath it, depth first: an instance's own, then its children's. at($path) returns the instance, among this one and its descendants, whose path equals the one given, or throws NoInstanceAt; has($path) says whether an instance or an open place is there. A path always runs from the root of the tree, so it means the same thing whichever instance is asked.

Instance::of($root, $supplied) takes metadata from outside the headspace, keyed by path: SuppliedMetadata::none()->at($path, $metadata). Each value is merged key by key over the metadata of what an instance is named after, and over an open place's requirement, and the value supplied wins. At a part, the part's metadata is merged over its concept's first. Paths that name nothing in the tree are refused together, in one NothingAt listing them all.

$headspace->entryPoints() lists the kinds no declaration in the headspace names, in declaration order, faulty declarations included: where to start reading it.

Files

A file holds one top-level declaration, with its headspace and, optionally, a group. Its locals and whatever is written in place are written inside it. Rmb32\ConceptManager\File reads and writes them, and depends on the engine, never the other way round.

use Rmb32\ConceptManager\File\DeclarationFile;
use Rmb32\ConceptManager\File\Reader;
use Rmb32\ConceptManager\File\Writer;

$file = new Reader()->fromPath('concepts/Homes/Kitchen.json'); // or fromJson($text), fromArray($decoded)
$file->headspace();   // 'Homes'
$file->group();       // 'Indoors', or null
$file->declaration(); // a ConceptDeclaration or a KindDeclaration, for Headspace::build

$json = new Writer()->toJson(DeclarationFile::of('Homes', $declaration, 'Indoors'));

Writer::toJson() writes pretty-printed JSON ending in a newline, with keys in the order the shape below gives them. It leaves out what says only what is so when it is left out: an empty list, empty metadata, a group of none, and a range's atLeast of 0, atMost of none and distinct of false. A part whose concept is written in place with the part's name and meaning, and nothing else, is written as part. toArray() gives the array that reading that JSON decodes. Reading what was written gives the declaration that was written. JSON cannot hold an infinite or NaN metadata value, or text that is not UTF-8: writing one throws Unwritable.

DeclarationFile::of($headspace, $declaration, $group) refuses a headspace, or a top-level name, that a filesystem would refuse or alter, since each becomes part of the path a file is kept at: UnsafeName says which and why (because(), an Unsafe). A name is unsafe if it is blank, holds any of / \ < > : " | ? * or a control character, starts with a dot, or ends with a space or a dot. Every other name, inside the declaration or of a group, is free text, and a group is refused, with BlankName, only if it is blank.

The shape

Every factory has exactly one written form, and nothing is inferred from where a record is written: a kind that fills is written differently from a standalone kind wherever it stands. Wherever a name may stand, a declaration may be written in place. Counts are written. Every list is a list of records, never an object keyed by name, so a name may be digits only. A key not marked optional is required, an unknown key is a fault, and nothing is cast: "1" is not 1.

File
{
    "headspace": string,            // a name a filesystem takes
    "group": string,                // optional; not blank
    "concept": Concept              // or "kind": Kind, exactly one of the two
}

Concept                             // ConceptDeclaration::new, or ::extending with "extends"
{
    "name": string,                 // not blank; at the top, a name a filesystem takes
    "meaning": string,              // not blank
    "extends": string,              // optional; "resolves" and "narrows" only with it
    "resolves": [Resolves],         // optional
    "narrows": [Narrows],           // optional
    "requires": [Requires],         // optional
    "provides": [Provides],         // optional
    "locals": [Local],              // optional; at the top only
    "metadata": Metadata            // optional
}

Kind                                // exactly one of "of", "standalone" and "fills"
{
    "name": string,
    "meaning": string,
    "of": string,                   // KindDeclaration::of, naming its concept
    "standalone": true,             // KindDeclaration::standalone: no "resolves"
    "fills": true,                  // KindDeclaration::filling: no "locals"
    "resolves": [Resolves],         // optional
    "provides": [Provides],         // optional
    "locals": [Local],              // optional; at the top only
    "metadata": Metadata            // optional
}

Local
{ "concept": Concept }              // or { "kind": Kind }, exactly one of the two

Requires                            // exactly one of "one" and "many"
{
    "name": string,
    "meaning": string,
    "one": string | Concept,        // Requires::one
    "many": string | Concept,       // Requires::many
    "atLeast": int,                 // optional, with "many" only; not negative
    "atMost": int,                  // optional, with "many" only; not negative
    "distinct": bool,               // optional, with "many" only
    "metadata": Metadata            // optional
}

Provides                            // exactly one of "one", "many", "part" and "partOf"
{
    "name": string,
    "meaning": string,
    "one": string | Kind,           // Provides::one
    "many": [string | Kind],        // Provides::many
    "part": {                       // Provides::part, a concept of its own
        "requires": [Requires],     //   optional
        "provides": [Provides]      //   optional
    },
    "partOf": string | Concept,     // Provides::partOf
    "metadata": Metadata            // optional
}

Resolves                            // exactly one of "requirement" and "within"
{
    "requirement": string,          // Resolves::to, with "kinds"
    "kinds": [string | Kind],
    "within": string,               // Resolves::within, naming a part
    "resolves": [Resolves],         // optional, with "within" only
    "narrows": [Narrows]            // optional, with "within" only
}

Narrows                             // Narrows::to; what is left out stays as it is
{
    "requirement": string,
    "concept": string | Concept,    // optional
    "atLeast": int,                 // optional; not negative
    "atMost": int,                  // optional; not negative
    "distinct": bool                // optional
}

Metadata
{ string: bool | int | float | string }   // a key not blank

Handling errors

A file that cannot be read throws NotReadable, and text that is not JSON throws NotJson, which says where it came from. A file that does not fit the shape throws one NotWellFormed carrying every fault at once, so an editor can show them all. Each is a typed-json Fault with the dotted path that caused it (*root* for the file itself), a message and a stable code:

use Rmb32\ConceptManager\File\Exception\NotWellFormed;

try {
    $file = new Reader()->fromJson($text);
} catch (NotWellFormed $exception) {
    foreach ($exception->faults() as $fault) {
        echo $fault->path(), ': ', $fault->message(), ' (', $fault->code(), ')', PHP_EOL;
    }
}
concept.requires.0: Exactly one of `one` and `many` is written (not_one_of)
concept.requires.1.colour: Unexpected key `colour`. (unexpected_key)
concept.provides.0.meaning: Value '' is not a valid non-empty string. (invalid_non_empty_string)

typed-json's codes say what does not fit a type: invalid_string, invalid_non_empty_string, invalid_boolean, invalid_list_key, unexpected_key, cannot_resolve_type_from_union and others, and a key that is missing is a value of the wrong type. Concept Manager adds its own: not_one_of and only_with for keys written together that may not be, unsafe_name, and, for what a declaration refuses where it is written, the exception's name: blank_name, blank_meaning, blank_key and not_top_level. A record's own fault is found once everything in it fits, so mending one fault can show another at the record that held it.

A file that fits the shape may still declare something a headspace refuses, such as a kind that does not fit or a name that is taken. That is found when the headspace is built, as a fault on the declaration (see Faults).

Where declarations live

Rmb32\ConceptManager\Store keeps declaration files in folders and finds them again by their headspace and name, a DeclarationId, not by their path. A name is unique within a headspace, so Library in Architecture and Library in Homes are two declarations. It depends on the files and the engine, never the other way round.

One store

FilesystemStore holds one folder. It finds every *.json file beneath it, at any depth, by the headspace and the concept's or kind's name inside it, reading only those two fields:

use Rmb32\ConceptManager\Store\DeclarationId;
use Rmb32\ConceptManager\Store\FilesystemStore;

$store = FilesystemStore::writable('/path/to/declarations'); // or ::readOnly()
$id = DeclarationId::of('Pets', 'House cat');

$store->ids();         // every DeclarationId it holds, by headspace and then by name
$store->holds($id);
$store->read($id);     // the DeclarationFile, read when asked for and kept
$store->write($file);  // a new one goes to <headspace>/<name>.json
$store->delete($id);

A declaration that already has a file keeps it when written again; one written under another id is a new file, and the caller deletes the old one. A read-only store refuses every change with ReadOnlySource.

What a source describes

Every headspace a declaration names, and every group, must be described: one headspaces.json at the top of a source says what each means. It is never read as a declaration.

{
    "headspaces": [
        { "name": "Pets", "meaning": "Animals kept at home: what kind each is and how it is looked after." }
    ],
    "groups": [
        { "headspace": "Pets", "name": "Cats", "meaning": "Felines kept indoors." }
    ]
}

Both lists are optional, every meaning must say something, and a source may describe a group in a headspace another source describes. A store reads it as descriptions(), a HeadspacesFile, and writes it through describeHeadspace(), describeGroup(), deleteHeadspace() (which takes that source's groups in it too) and deleteGroup(), each taking a DescribedHeadspace or a DescribedGroup. A headspaces.json that does not fit is one fault listing every problem in it, and is left as it is rather than overwritten: changing what that source describes throws BrokenFile until it is mended. Reader::headspacesFromPath() and Writer::headspacesToJson() read and write one directly.

A declaration whose headspace, or whose group, no source describes is a fault and is not served. That turns a misspelt "Petz" into an error rather than a new headspace.

Names that differ only in case are the same name, since a filesystem that ignores case would keep them at one path: two headspaces, two declarations in one headspace, and two groups in one headspace.

Layered sources

A Registry is a composite over an ordered list of sources, of which one at most can be written to (MoreThanOneWritable otherwise). That is how a shipped, read-only catalogue sits beside the folder you write to:

use Rmb32\ConceptManager\Store\Registry;

$registry = Registry::over(
    FilesystemStore::readOnly('/path/to/vendor/acme/catalogue'),
    FilesystemStore::writable('/path/to/my/declarations'),
);

Sources do not shadow one another. The first source to hold a declaration serves it, and the same id in a later source is a fault on the later file, as two files in one source are. The same goes for descriptions: a headspace, or a group within a headspace, that a later source describes again is a fault on that source's headspaces.json. A clash by case alone counts as the same name.

ids() is every declaration the sources hold, each once. headspaces() and groups($headspace) are every source's descriptions, by name. get($id) returns the file from the source that serves it, and throws NotDescribed for one whose headspace or group is not described. origin($id) says which source serves it, pathOf($id) which file, and isWritable($id) whether it can be changed where it is, so a reader can label "shipped" against "yours".

put($file) writes to the writable source. It refuses a declaration another source holds, or holds but for case (AlreadyHeld, naming that source), and one whose headspace or group is not described; with no writable source it throws NoWritableSource rather than keep it in memory and lose it. delete($id) refuses what a read-only source holds. describeHeadspace() and describeGroup() write to the writable source too, and refuse what another source describes (DescribedTwice); a DescribedHeadspace or DescribedGroup already refuses what a reader of headspaces.json would, so describing never leaves a file the source cannot read. deleteHeadspace() and deleteGroup() refuse while a declaration is still in it (InUse, listing them), and what another source describes (ReadOnlySource).

What the sources hold is gathered afresh on every call, since a source may be changed directly.

One bad file is one fault

A file that will not read, will not decode, names no headspace or declaration, or does not fit the shape is a FileFault on faults(), never a throw that takes the other forty declarations with it. Each carries the source it came from, the file, and the exception saying what is wrong with it. Asking for a broken one by id still throws: BrokenFile, naming the file, with the real cause as its getPrevious().

Building a headspace

$registry->headspace('Homes') builds the engine's Headspace from every declaration served in it, with the meaning its source describes. A declaration whose file cannot be read, or whose group is not described, is still declared, with that fault, so a declaration that uses it gets DependsOnAFault rather than UnknownConcept. Headspace::build takes such a Fault wherever it takes a declaration, for anyone who reads declarations some other way.

faults() builds every headspace too, so what a headspace refuses (a kind that does not fit, a concept nobody declared) is reported with the file of the declaration it belongs to. It reads every file; ids() does not.

Set up a project

concept-manager.json at the top of a project names the folder its own declarations are kept in and the folders it borrows from, read-only and in order, each relative to the project:

{
    "declarations": "declarations",
    "borrowFrom": ["vendor/acme/catalogue/declarations"]
}
use Rmb32\ConceptManager\File\ProjectConfig;
use Rmb32\ConceptManager\Store\ProjectConfigFile;
use Rmb32\ConceptManager\Store\Registry;

ProjectConfigFile::save($root, ProjectConfig::of('declarations', ['vendor/acme/catalogue/declarations']));

$config = ProjectConfigFile::load($root); // null for a project without one
$registry = Registry::fromConfig($root, $config);

save() also makes the declarations folder. fromConfig() puts the borrowed folders first, in order, and the project's own last, as the one written to. A folder that is blank, absolute or climbs out through .. is refused with NotInTheProject, whether it is given or read.

Checking a change

A change to one declaration reaches everything built with it, and can break it: drop a requirement a kind resolves, and the kind no longer builds. Rmb32\ConceptManager\Check\Checker says so before anything is written:

use Rmb32\ConceptManager\Check\Checker;
use Rmb32\ConceptManager\Store\DeclarationId;

$checker = Checker::over($registry);
$room = DeclarationId::of('Homes', 'Room');

$checker->statuses();                    // every declaration's Status, by headspace, then name
$checker->faults();                      // the FileFaults no status carries
$checker->usersOf($room);                // [Bedroom in Homes, Kitchen in Homes]
$checker->impactOfSaving($edited);       // what saving this edited Room would change
$checker->impactOfSaving($moved, $room); // what moving it to another headspace would change
$checker->impactOfDeleting($room);

Statuses

A Status is one of three, and says which through isFaulty(), isOpen() and isComplete():

  • faulty: it does not build. It carries its fault(), the first the headspace found in it, and the path() of its file;
  • open: a concept that builds with places left open. openPlaces() is the path of each, depth first, as its outline gives them;
  • complete: a kind, or a concept with nothing left open.

A file that serves no declaration has no status to carry its fault: one that is not JSON, names no declaration or names one another file serves already, and a headspaces.json that cannot be read or describes what another source does. faults() lists those, as Registry::unserved() does, so statuses and faults together are everything Registry::faults() reports.

Users

usersOf($id) is every declaration in the same headspace that names it, directly or through others, by name. A declaration names what its references() gives, which takes in what its inline declarations and its locals name. A name in another headspace is another declaration, and one that cannot be served names nothing.

Impact

A headspace is built whole, so the impact of a change is two builds compared: each headspace the change touches as it is, and as it would be. impactOfSaving($file, $replacing) and impactOfDeleting($id) return a list of Change, one for each declaration in those headspaces whose status would change, the one changed among them, by headspace and then by name. Each has before() and after(), and:

  • breaks(): it builds now and would not after;
  • mends(): the reverse;
  • newlyOpen(): the places it would leave open that it does not now.

Two faulty statuses are alike whatever their faults, so a declaration that was faulty and still is has not changed, and neither has one whose meaning alone was reworded. Saving under another id than the one replaced, as a rename or a move to another headspace is, leaves the replaced id gone, and both headspaces are compared. A declaration only one side holds, such as a new one or the old id of a rename, is not a change itself; what it does to the rest is.

Nothing is written, and nothing is refused: a proposal put() would refuse, such as one another source holds, is checked as if it were kept. Whether to go ahead is the caller's choice.

Projecting a tree

A kind's tree, or a concept's outline, is projected for whoever will read it. Rmb32\ConceptManager\Projection depends on the engine, and on the files only for the rules a folder name keeps, never the other way round. Four projectors are built in, each with a kind and a label:

KindLabelWhat you getFor
textTreeone .txt file: one name per line, beneath its parenta quick look
markdownDocumentone .md file: a heading per instance, its meaning beneathpeople
folderFoldersa folder per instance, each with a README.mddevelopers and tools
mermaidDiagramone .mmd file: a Mermaid flowcharta README, a pull request or a slide
use Rmb32\ConceptManager\Concept\Instance\Instance;
use Rmb32\ConceptManager\Projection\ProjectionKind;
use Rmb32\ConceptManager\Projection\Projectors;
use Rmb32\ConceptManager\Projection\Writer;

$projectors = Projectors::builtIn();
$document = $projectors->get(ProjectionKind::of('markdown'))->project(Instance::of($kind));

$document->content();   // the Markdown
$document->mediaType(); // 'text/markdown'
$document->extension(); // 'md'

new Writer()->write($document, 'docs/orchard-lane.md');

project() never touches the disk. It returns a FileProjection, with its content(), mediaType() and extension(), or a FolderProjection, whose root() is a Folder of files() and folders(). kinds() and projectors() list what a registry holds, in order, and asking for a kind none makes throws NoProjector, naming those there are.

What each shows

An instance is shown by its name() and meaning(). One that fills a requirement, rather than standing at a part, is a kind, named after itself, so each projection also says which requirement it fills.

  • Tree: each name on a line of its own, indented beneath its parent with | and +--. Each open place follows the children of the instance that leaves it open, as Boundary (open).
  • Document: the root is the # title, with its meaning as the opening paragraph. Each child is a heading one level deeper, down to ######, below which children are a nested list of - **name** — meaning. A child that fills a requirement is headed Kitchen: Our kitchen. Open places are listed under the meaning, before the children, as - **Boundary** (open) — meaning. Names are escaped, so *best* is shown as written; meanings are written as they are, and may hold Markdown.
  • Folders: a folder per instance, named after it, holding a README.md with its name as the title and its meaning beneath; one that fills a requirement says so (Fills *Flooring*.). An open place makes no folder.
  • Diagram: a flowchart TD, with node ids n1, n2… in walk order. An edge to a child that fills a requirement is labelled with it, and an open place is a dashed node on a dotted edge. Labels are escaped so that a name can neither end its label nor be read as maths: $ is written #36;. Meanings are left out.

Folder names are checked, never changed. A name holding any of / \ < > : " | ? * or a control character, or ending with a dot or a space, cannot be a folder, and neither can two siblings whose names differ only in case, or a child named like the README.md beside it. One kind taken twice by a requirement for many is two siblings of one name. Every such name is refused together, in one FolderNames, before anything is made. Each FolderNameProblem has the folders() from the root's down to its own, its path() in the tree and its reason(), an Unsafe or a Clash, and FolderProjector::problems() lists them without throwing. Unlike a declaration's name, a folder may start with a dot.

Writing one

Writer::write($projection, $path) puts a projection on disk, and never overwrites or merges. A file goes to a path where nothing is, and the folders it is in are made. A folder projection goes to a path where nothing is, or to an empty folder, which becomes the root's folder. Anything else there, a link included, throws TargetExists, and a file or folder the filesystem will not let it make throws CannotWrite.

Adding a projection

Implement Projector and add it to the registry. A kind is an open value, so any package can name one:

use Rmb32\ConceptManager\Concept\Instance\Instance;
use Rmb32\ConceptManager\Projection\FileProjection;
use Rmb32\ConceptManager\Projection\FolderProjection;
use Rmb32\ConceptManager\Projection\ProjectionKind;
use Rmb32\ConceptManager\Projection\Projector;
use Rmb32\ConceptManager\Projection\Projectors;

final readonly class SlidesProjector implements Projector
{
    public function kind(): ProjectionKind
    {
        return ProjectionKind::of('slides');
    }

    public function label(): string
    {
        return 'Slides';
    }

    public function project(Instance $instance): FileProjection|FolderProjection
    {
        return FileProjection::of($this->kind(), '…', 'text/markdown', 'md');
    }
}

$projectors = Projectors::builtIn()->with(new SlidesProjector());

with() returns a new registry, with the projector added, or put in place of the one of its kind. Every projection is a file or a folder of files, so the writer, and any host, handles a new kind with no more work.

Examples

A house with parts

A house whose ground floor is a part: always there, holding a hallway, a living room that is a Room, and a kitchen still to choose. 12 Orchard Lane resolves within the ground floor, and within the living room inside it, and invents no name to do so. This is examples/house.php, which the tests run.

use Rmb32\ConceptManager\Concept\Declaration\ConceptDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\KindDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\Provides;
use Rmb32\ConceptManager\Concept\Declaration\Requires;
use Rmb32\ConceptManager\Concept\Declaration\Resolves;
use Rmb32\ConceptManager\Concept\Headspace\Headspace;
use Rmb32\ConceptManager\Concept\Instance\Instance;

$headspace = Headspace::build(
    'Homes',
    'Houses and the rooms they are made of.',
    ConceptDeclaration::new('House', 'Somewhere to live.', provides: [
        Provides::part('Ground floor', 'The rooms you reach from the front door.', [
            Requires::one('Kitchen', 'Where the cooking is done.', 'Kitchen'),
        ], [
            Provides::part('Hallway', 'Where the front door opens.'),
            Provides::partOf('Living room', 'Where the household sits.', 'Room'),
        ]),
    ]),
    ConceptDeclaration::new('Room', 'Four walls, with the floor still to choose.', requires: [
        Requires::one('Flooring', 'What the floor is finished with.', 'Flooring'),
    ]),
    ConceptDeclaration::new('Kitchen', 'Where the cooking is done.'),
    ConceptDeclaration::new('Flooring', 'A finished floor.'),
    KindDeclaration::of('Galley', 'Units along both walls.', 'Kitchen'),
    KindDeclaration::of('Oak boards', 'Wide oak boards, oiled.', 'Flooring'),
    KindDeclaration::of('12 Orchard Lane', 'A brick house by the green.', 'House', resolves: [
        Resolves::within('Ground floor', [
            Resolves::to('Kitchen', 'Galley'),
            Resolves::within('Living room', [Resolves::to('Flooring', 'Oak boards')]),
        ]),
    ]),
    KindDeclaration::of('14 Orchard Lane', 'Next door, half planned.', 'House', resolves: [
        Resolves::within('Ground floor', [Resolves::to('Kitchen', 'Galley')]),
    ]),
);

$house = $headspace->concept('House');
$groundFloor = $house->requirements()->get('Ground floor');

echo 'House leaves open: ', implode(', ', $house->requirements()->unmetNames()), PHP_EOL;
echo 'Ground floor is a part: ', $groundFloor->isPart() ? 'yes' : 'no', PHP_EOL;

$root = Instance::of($headspace->kind('12 Orchard Lane'));

foreach ([$root, ...$root->descendants()] as $instance) {
    $steps = $instance->path()->steps();
    $place = $steps === [] ? null : $steps[count($steps) - 1]->name();
    $name = $instance->name();
    $label = $place === null || $place === $name ? $name : $place . ': ' . $name;
    echo str_repeat('  ', count($steps)), $label, PHP_EOL;
}

foreach ($headspace->faults() as $fault) {
    echo 'Fault in ', $fault->name(), ': ', $fault->exception()->getMessage(), PHP_EOL;
}

It prints:

House leaves open: Ground floor
Ground floor is a part: yes
12 Orchard Lane
  Ground floor
    Hallway
    Living room
      Flooring: Oak boards
    Kitchen: Galley
Fault in 14 Orchard Lane: Kind 14 Orchard Lane leaves requirements unmet: Ground floor/Living room/Flooring

House leaves one thing open, its ground floor, and a kind of it must meet everything inside: the fault names the place by its path. The hallway and the living room appear under the names of their places: each part is met by a kind named after it.

A library with locals and metadata

A library whose Exceptions folder sits under both its Api and its Internal part. It is written once, as a local of Library, so it takes no name in the headspace. Metadata says what an exception's slot declares and which language a class is in; a kind of Class may add to it and never change it. This is examples/library.php, which the tests run.

use Rmb32\ConceptManager\Concept\Concept;
use Rmb32\ConceptManager\Concept\Declaration\ConceptDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\KindDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\Provides;
use Rmb32\ConceptManager\Concept\Declaration\Requires;
use Rmb32\ConceptManager\Concept\Declaration\Resolves;
use Rmb32\ConceptManager\Concept\Headspace\Headspace;
use Rmb32\ConceptManager\Concept\Instance\Instance;
use Rmb32\ConceptManager\Concept\UnmetRequirement;

$headspace = Headspace::build(
    'Libraries',
    'How a package is laid out.',
    ConceptDeclaration::new('Library', 'A public surface over hidden code.', provides: [
        Provides::part('Api', 'What a client may use.', provides: [
            Provides::partOf('Exceptions', 'Failures a client may catch.', 'Exceptions'),
        ]),
        Provides::part('Internal', 'What a client never sees.', provides: [
            Provides::partOf('Exceptions', 'Failures kept inside.', 'Exceptions'),
        ]),
    ], locals: [
        ConceptDeclaration::new('Exceptions', 'A folder of failures.', [
            Requires::many('Exception', 'One failure.', 'Class', metadata: ['declares' => 'exception']),
        ]),
    ]),
    ConceptDeclaration::new('Class', 'A unit of code.', metadata: ['language' => 'php']),
    ConceptDeclaration::new('Package', 'Something to install.', [
        Requires::one('Main', 'Where it starts.', 'Class'),
    ]),
    KindDeclaration::of('Billing', 'Sends bills.', 'Package', [
        Resolves::to('Main', KindDeclaration::of('Invoice', 'What is owed.', 'Class', metadata: [
            'final' => true,
        ])),
    ]),
    KindDeclaration::of('Shipping', 'Sends parcels.', 'Package', [
        Resolves::to('Main', KindDeclaration::of('Parcel', 'What is sent.', 'Class', metadata: [
            'language' => 'go',
        ])),
    ]),
);

$library = $headspace->concept('Library');
$held = [];

foreach (['Api', 'Internal'] as $part) {
    $holding = $library->requirements()->get($part);
    $exceptions = $holding instanceof UnmetRequirement
        ? $holding->concept()->requirements()->get('Exceptions')
        : null;
    $held[] = $exceptions instanceof UnmetRequirement ? $exceptions->concept() : null;
}

echo 'Concepts listed: ', implode(', ', array_map(
    static fn (Concept $concept): string => $concept->name(),
    $headspace->concepts(),
)), PHP_EOL;
echo 'One Exceptions under both: ', $held[0] !== null && $held[0] === $held[1] ? 'yes' : 'no', PHP_EOL;
echo 'Library leaves open: ', implode(', ', $library->requirements()->unmetPaths()), PHP_EOL;
echo 'Exception declares: ', $held[0]?->requirements()->get('Exception')->metadata()->get('declares'), PHP_EOL;

foreach (Instance::of($headspace->kind('Billing'))->descendants() as $instance) {
    echo $instance->path(), ': ', $instance->name(), ' ', json_encode($instance->metadata()->toArray()), PHP_EOL;
}

foreach ($headspace->faults() as $fault) {
    echo 'Fault in ', $fault->name(), ': ', $fault->exception()->getMessage(), PHP_EOL;
}

It prints:

Concepts listed: Library, Class, Package
One Exceptions under both: yes
Library leaves open: Api/Exceptions/Exception, Internal/Exceptions/Exception
Exception declares: exception
Main: Invoice {"language":"php","final":true}
Fault in Shipping: Metadata language is 'php', set on Class, so Parcel cannot make it 'go'

Exceptions is one concept at both places, and only Library sees it. Invoice carries the language of Class and adds that it is final; Parcel tries to change the language, and the fault names where it was set.

A layered application

A layered application: each context holds a domain of its own choosing, plus a fixed public API. The declarations come top-down, in no particular order. This is examples/layered-app.php, which the tests run.

use Rmb32\ConceptManager\Concept\Declaration\ConceptDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\KindDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\Provides;
use Rmb32\ConceptManager\Concept\Declaration\Requires;
use Rmb32\ConceptManager\Concept\Declaration\Resolves;
use Rmb32\ConceptManager\Concept\Headspace\Headspace;
use Rmb32\ConceptManager\Concept\Instance\Instance;

$headspace = Headspace::build(
    'Layered apps',
    'Applications split into contexts, each with its own domain.',
    ConceptDeclaration::new('App', 'A whole application.', requires: [
        Requires::many('Contexts', 'The parts it is split into.', 'Context'),
    ]),
    ConceptDeclaration::new(
        'Context',
        'One part of the business.',
        requires: [Requires::one('Domain', 'Its own rules.', 'Domain')],
        provides: [Provides::one('Public API', 'Its face to others.', 'Public API')],
    ),
    ConceptDeclaration::new('Domain', 'The rules of one context.', requires: [
        Requires::many('Aggregates', 'What the rules govern.', 'Aggregate', atMost: 7),
    ]),
    ConceptDeclaration::new('Aggregate', 'A cluster changed as one.'),
    KindDeclaration::standalone('Public API', 'What a context offers others.'),
    KindDeclaration::of('Shop', 'An online shop.', 'App', resolves: [
        Resolves::to('Contexts', ['Ordering', 'Billing']),
    ]),
    KindDeclaration::of('Ordering', 'Taking orders.', 'Context', resolves: [
        Resolves::to('Domain', 'Ordering domain'),
    ]),
    KindDeclaration::of('Billing', 'Sending bills.', 'Context', resolves: [
        Resolves::to('Domain', 'Empty domain'),
    ]),
    KindDeclaration::of('Ordering domain', 'The rules of ordering.', 'Domain', resolves: [
        Resolves::to('Aggregates', ['Order', 'Basket']),
    ]),
    KindDeclaration::of('Empty domain', 'Nothing in it yet.', 'Domain', resolves: [
        Resolves::to('Aggregates', []),
    ]),
    KindDeclaration::of('Order', 'What a customer asks for.', 'Aggregate'),
    KindDeclaration::of('Basket', 'What a customer means to buy.', 'Aggregate'),
    KindDeclaration::of('Sprawling domain', 'Everything in one place.', 'Domain', resolves: [
        Resolves::to('Aggregates', array_fill(0, 8, 'Order')),
    ]),
    KindDeclaration::of('Undecided domain', 'Not thought about yet.', 'Domain'),
);

$shop = $headspace->kind('Shop');
$context = $headspace->concept('Context');

echo 'Shop leaves nothing open: ', $shop->requirements()->areMet() ? 'yes' : 'no', PHP_EOL;
echo 'Context leaves open: ', implode(', ', $context->requirements()->unmetNames()), PHP_EOL;
echo 'Kinds of Context: ', implode(', ', $headspace->kindsOf('Context')), PHP_EOL;

$root = Instance::of($shop);

foreach ([$root, ...$root->descendants()] as $instance) {
    $steps = $instance->path()->steps();
    $place = $steps === [] ? '' : $steps[count($steps) - 1] . ': ';
    echo str_repeat('  ', count($steps)), $place, $instance->name(), PHP_EOL;
}

foreach ($headspace->faults() as $fault) {
    echo 'Fault in ', $fault->name(), ': ', $fault->exception()->getMessage(), PHP_EOL;
}

It prints:

Shop leaves nothing open: yes
Context leaves open: Domain
Kinds of Context: Ordering, Billing
Shop
  Contexts[0]: Ordering
    Domain: Ordering domain
      Aggregates[0]: Order
      Aggregates[1]: Basket
    Public API: Public API
  Contexts[1]: Billing
    Domain: Empty domain
    Public API: Public API
Fault in Sprawling domain: Requirement Aggregates takes at most 7 kinds, not 8
Fault in Undecided domain: Kind Undecided domain leaves requirements unmet: Aggregates

An empty domain is a decision; a domain that says nothing about its aggregates is refused, and so is one with more than the seven a domain takes. The rest of the headspace is built anyway. Public API appears under both contexts: one kind, two instances.

A garden, before anything is chosen

Barncept's garden: a shed and a path, with its boundary and planting still to choose. Its outline lists what is open at each place, metadata is supplied for the shed and the boundary, and the headspace says where to start. This is examples/garden.php, which the tests run.

use Rmb32\ConceptManager\Concept\Declaration\ConceptDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\KindDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\Provides;
use Rmb32\ConceptManager\Concept\Declaration\Requires;
use Rmb32\ConceptManager\Concept\Declaration\Resolves;
use Rmb32\ConceptManager\Concept\Headspace\Headspace;
use Rmb32\ConceptManager\Concept\Instance\Instance;
use Rmb32\ConceptManager\Concept\Instance\OpenPlace;
use Rmb32\ConceptManager\Concept\Instance\Path;
use Rmb32\ConceptManager\Concept\Instance\Step;
use Rmb32\ConceptManager\Concept\Instance\SuppliedMetadata;
use Rmb32\ConceptManager\Concept\Kind;
use Rmb32\ConceptManager\Concept\Metadata;

$headspace = Headspace::build(
    'Homes',
    'Houses and the ground around them.',
    ConceptDeclaration::new('Garden', 'Ground outside the house, with a shed in one corner.', requires: [
        Requires::one('Boundary', 'What marks where the garden ends.', 'Boundary', ['visible' => true]),
        Requires::many('Planting', 'What grows in the beds.', 'Plant'),
    ], provides: [
        Provides::part('Shed', 'Where the tools are kept.', [
            Requires::many('Tools', 'What hangs on the wall.', 'Tool'),
        ]),
        Provides::part('Path', 'Runs from the back door to the shed.'),
    ]),
    ConceptDeclaration::new('Boundary', 'Where one plot meets the next.'),
    ConceptDeclaration::new('Plant', 'Something that grows.'),
    ConceptDeclaration::new('Tool', 'Something to work the ground with.'),
    KindDeclaration::of('Hedge', 'Privet, kept low.', 'Boundary'),
    KindDeclaration::of('Roses', 'Climbing, by the door.', 'Plant'),
    KindDeclaration::of('Our garden', 'Behind 12 Orchard Lane.', 'Garden', resolves: [
        Resolves::to('Boundary', 'Hedge'),
        Resolves::to('Planting', ['Roses']),
        Resolves::within('Shed', [Resolves::to('Tools', [])]),
    ]),
);

$outline = Instance::of($headspace->concept('Garden'), SuppliedMetadata::none()
    ->at(Path::of(Step::of('Shed')), Metadata::of(['wrapper' => true]))
    ->at(Path::of(Step::of('Boundary')), Metadata::of(['height' => 2])));

$said = static fn (Metadata $metadata): string => $metadata->keys() === []
    ? ''
    : ' ' . json_encode($metadata->toArray());

foreach ([$outline, ...$outline->descendants()] as $instance) {
    $depth = count($instance->path()->steps());
    echo str_repeat('  ', $depth), $instance->name(), $said($instance->metadata()), PHP_EOL;

    foreach ($instance->open() as $place) {
        $name = $place->requirement()->name();
        echo str_repeat('  ', $depth + 1), $name, ' (open)', $said($place->metadata()), PHP_EOL;
    }
}

echo 'Open: ', implode(', ', array_map(
    static fn (OpenPlace $place): string => (string) $place->path(),
    $outline->openPlaces(),
)), PHP_EOL;
echo 'Start from: ', implode(', ', array_map(
    static fn (Kind $kind): string => $kind->name(),
    $headspace->entryPoints(),
)), PHP_EOL;

It prints:

Garden
  Boundary (open) {"visible":true,"height":2}
  Planting (open)
  Shed {"wrapper":true}
    Tools (open)
  Path
Open: Boundary, Planting, Shed/Tools
Start from: Our garden

The shed is a part, so it is laid out though its tools are open; the boundary is a requirement, so nothing is laid out beneath it. Our garden is the one kind nothing names: the hedge and the roses are named by it.

A source tree

A folder holds folders, so Folder asks for itself, and the requirement it asks with holds the very concept the headspace returns. Each kind names the folders inside it and ends with an empty list. This is examples/source-tree.php, which the tests run.

use Rmb32\ConceptManager\Concept\Declaration\ConceptDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\KindDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\Requires;
use Rmb32\ConceptManager\Concept\Declaration\Resolves;
use Rmb32\ConceptManager\Concept\Headspace\Headspace;
use Rmb32\ConceptManager\Concept\Instance\Instance;
use Rmb32\ConceptManager\Concept\UnmetRequirement;

$headspace = Headspace::build(
    'Source trees',
    'The folders a project keeps its code in.',
    ConceptDeclaration::new('Folder', 'A place that holds files and other folders.', requires: [
        Requires::many('Folders', 'The folders inside it.', 'Folder'),
    ]),
    KindDeclaration::of('src', 'Where the code lives.', 'Folder', resolves: [
        Resolves::to('Folders', ['Domain', 'Infrastructure']),
    ]),
    KindDeclaration::of('Domain', 'The rules.', 'Folder', resolves: [
        Resolves::to('Folders', [
            KindDeclaration::of('Model', 'What the rules are about.', 'Folder', resolves: [
                Resolves::to('Folders', []),
            ]),
        ]),
    ]),
    KindDeclaration::of('Infrastructure', 'How the rules reach the world.', 'Folder', resolves: [
        Resolves::to('Folders', []),
    ]),
    KindDeclaration::of('Mirror', 'A folder inside itself.', 'Folder', resolves: [
        Resolves::to('Folders', ['Mirror']),
    ]),
);

$folder = $headspace->concept('Folder');
$inside = $folder->requirements()->get('Folders');
$asksForItself = $inside instanceof UnmetRequirement && $inside->concept() === $folder;

echo 'Folder asks for itself: ', $asksForItself ? 'yes' : 'no', PHP_EOL;
echo 'Kinds of Folder: ', implode(', ', $headspace->kindsOf('Folder')), PHP_EOL;

$root = Instance::of($headspace->kind('src'));

foreach ([$root, ...$root->descendants()] as $instance) {
    $depth = count($instance->path()->steps());
    echo str_repeat('  ', $depth), $instance->name(), '/', PHP_EOL;
}

foreach ($headspace->faults() as $fault) {
    echo 'Fault in ', $fault->name(), ': ', $fault->exception()->getMessage(), PHP_EOL;
}

It prints:

Folder asks for itself: yes
Kinds of Folder: src, Domain, Infrastructure
src/
  Domain/
    Model/
  Infrastructure/
Fault in Mirror: Declared in a loop: Mirror

Mirror fills its own Folders with itself, so its tree would never end: asking for itself is sound, a kind holding itself is not.

A kitchen in files

Two files, a concept and a kind of it, read and built into one headspace; then a file that does not fit, with every fault in it at once. The kind's units and range are kinds that fill, written in place. This is examples/files.php, which the tests run.

use Rmb32\ConceptManager\Concept\Headspace\Headspace;
use Rmb32\ConceptManager\Concept\Instance\Instance;
use Rmb32\ConceptManager\File\Exception\NotWellFormed;
use Rmb32\ConceptManager\File\Reader;

$reader = new Reader();

$kitchen = $reader->fromJson(<<<'JSON'
    {
        "headspace": "Homes",
        "group": "Indoors",
        "concept": {
            "name": "Kitchen",
            "meaning": "Where meals are made.",
            "requires": [
                { "name": "Cooker", "meaning": "What heats the food.", "one": "Cooker" }
            ],
            "provides": [
                { "name": "Worktop", "meaning": "Where food is prepared.", "part": {
                    "requires": [
                        { "name": "Units", "meaning": "What sits beneath it.", "many": "Unit", "atLeast": 1 }
                    ]
                } }
            ],
            "locals": [
                { "concept": { "name": "Cooker", "meaning": "Something that cooks." } },
                { "concept": { "name": "Unit", "meaning": "A cupboard or drawers." } }
            ]
        }
    }
    JSON);

$ours = $reader->fromJson(<<<'JSON'
    {
        "headspace": "Homes",
        "group": "Indoors",
        "kind": {
            "name": "Our kitchen",
            "meaning": "Painted units and a range.",
            "of": "Kitchen",
            "resolves": [
                { "requirement": "Cooker", "kinds": [
                    { "name": "Range cooker", "meaning": "Cast iron, two ovens.", "fills": true }
                ] },
                { "within": "Worktop", "resolves": [
                    { "requirement": "Units", "kinds": [
                        { "name": "Painted units", "meaning": "Shaker doors, painted sage.", "fills": true }
                    ] }
                ] }
            ],
            "metadata": { "built": 1998 }
        }
    }
    JSON);

$headspace = Headspace::build(
    $kitchen->headspace(),
    'Houses and the rooms in them.',
    $kitchen->declaration(),
    $ours->declaration(),
);
$tree = Instance::of($headspace->kind('Our kitchen'));

foreach ([$tree, ...$tree->descendants()] as $instance) {
    echo str_repeat('  ', count($instance->path()->steps())), $instance->name(), PHP_EOL;
}

try {
    $reader->fromJson(<<<'JSON'
        {
            "headspace": "Homes",
            "concept": {
                "name": "Larder",
                "meaning": "Where food is kept cool.",
                "requires": [
                    { "name": "Shelves", "meaning": "What holds the jars.", "one": "Shelf", "many": "Shelf" },
                    { "name": "Door", "meaning": "The way in.", "one": "Door", "colour": "green" }
                ],
                "provides": [
                    { "name": "Light", "meaning": "", "part": {} }
                ]
            }
        }
        JSON);
} catch (NotWellFormed $exception) {
    foreach ($exception->faults() as $fault) {
        echo $fault->path(), ': ', $fault->message(), ' (', $fault->code(), ')', PHP_EOL;
    }
}

It prints:

Our kitchen
  Worktop
    Painted units
  Range cooker
concept.requires.0: Exactly one of `one` and `many` is written (not_one_of)
concept.requires.1.colour: Unexpected key `colour`. (unexpected_key)
concept.provides.0.meaning: Value '' is not a valid non-empty string. (invalid_non_empty_string)

The headspace name comes from the file; its meaning does not, since a file holds a declaration, not a headspace. The larder's Light has an empty meaning, which typed-json refuses as a string that may not be empty; one of spaces only would be blank_meaning, at concept.provides.0.

A shipped catalogue and your own folder

A read-only source that describes Homes and holds a Kitchen, beside your own folder, which describes Pets and is written to through the registry. A copy of the shipped kitchen in your folder does not shadow it, and a shipped file that is not JSON is one fault, not the end of the source. This is examples/storage.php, which the tests run.

use Rmb32\ConceptManager\Concept\Declaration\ConceptDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\KindDeclaration;
use Rmb32\ConceptManager\File\DeclarationFile;
use Rmb32\ConceptManager\File\DescribedHeadspace;
use Rmb32\ConceptManager\File\Writer;
use Rmb32\ConceptManager\Store\FilesystemStore;
use Rmb32\ConceptManager\Store\Registry;

$root = sys_get_temp_dir() . '/concept-manager-storage-' . getmypid();
$shipped = $root . '/shipped';
$mine = $root . '/mine';
mkdir($shipped, recursive: true);
mkdir($mine, recursive: true);

$kitchen = new Writer()->toJson(DeclarationFile::of(
    'Homes',
    ConceptDeclaration::new('Kitchen', 'Where meals are made.'),
));

// The shipped catalogue describes the headspace its declarations are in.
file_put_contents($shipped . '/headspaces.json', <<<'JSON'
    {
        "headspaces": [
            { "name": "Homes", "meaning": "Houses and the rooms in them." }
        ]
    }
    JSON);
file_put_contents($shipped . '/kitchen.json', $kitchen);
file_put_contents($shipped . '/larder.json', '{"headspace": ');

// A copy of a shipped declaration does not shadow it: it is a fault.
file_put_contents($mine . '/kitchen.json', $kitchen);

$registry = Registry::over(FilesystemStore::readOnly($shipped), FilesystemStore::writable($mine));

// Your own folder describes a headspace of its own, and writes into both.
$registry->describeHeadspace(DescribedHeadspace::of('Pets', 'Animals kept at home.'));
$registry->put(DeclarationFile::of('Pets', ConceptDeclaration::new('Cat', 'A small feline.')));
$registry->put(DeclarationFile::of('Homes', KindDeclaration::of('Galley', 'Long and thin.', 'Kitchen')));

foreach ($registry->headspaces() as $headspace) {
    echo $headspace->name(), ': ', $headspace->meaning(), PHP_EOL;

    foreach ($registry->ids() as $id) {
        if ($id->headspace() === $headspace->name()) {
            printf(
                "  %-8s from %-8s %s\n",
                $id->name(),
                basename($registry->origin($id)),
                $registry->isWritable($id) ? '(yours)' : '(read-only)',
            );
        }
    }
}

$homes = $registry->headspace('Homes');
echo 'Kinds of Kitchen: ', implode(', ', $homes->kindsOf('Kitchen')), PHP_EOL;

foreach ($registry->faults() as $fault) {
    echo 'fault: ', str_replace($root . '/', '', (string) $fault), PHP_EOL;
}

$remove = static function (string $path) use (&$remove): void {
    foreach (glob($path . '/*') ?: [] as $entry) {
        is_dir($entry) ? $remove($entry) : unlink($entry);
    }

    rmdir($path);
};
$remove($root);

It prints:

Homes: Houses and the rooms in them.
  Galley   from mine     (yours)
  Kitchen  from shipped  (read-only)
Pets: Animals kept at home.
  Cat      from mine     (yours)
Kinds of Kitchen: Galley
fault: shipped/larder.json is not JSON: Syntax error
fault: mine/kitchen.json: Kitchen in Homes is already held by shipped

Galley, a kind of the shipped Kitchen, is built from your folder into the same headspace.

Checking a change before it is kept

A Room asks for its Flooring, a kind of room resolves it, and a concept extending room leaves it open. Saving Room without it breaks the kind; saving it with Lighting as well breaks the kind again and opens a place in everything else. This is examples/checking.php, which the tests run.

use Rmb32\ConceptManager\Check\Checker;
use Rmb32\ConceptManager\Concept\Declaration\ConceptDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\KindDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\Requires;
use Rmb32\ConceptManager\Concept\Declaration\Resolves;
use Rmb32\ConceptManager\File\DeclarationFile;
use Rmb32\ConceptManager\File\DescribedHeadspace;
use Rmb32\ConceptManager\Store\DeclarationId;
use Rmb32\ConceptManager\Store\FilesystemStore;
use Rmb32\ConceptManager\Store\Registry;

$root = sys_get_temp_dir() . '/concept-manager-checking-' . getmypid();
$registry = Registry::over(FilesystemStore::writable($root));
$registry->describeHeadspace(DescribedHeadspace::of('Homes', 'Houses and the rooms in them.'));

$flooring = Requires::one('Flooring', 'What it stands on.', 'Finish');
$lighting = Requires::one('Lighting', 'What lights it.', 'Finish');
$room = static fn (Requires ...$requires): DeclarationFile => DeclarationFile::of(
    'Homes',
    ConceptDeclaration::new('Room', 'A space in a house.', requires: array_values($requires)),
);

$registry->put($room($flooring));

foreach (
    [
        ConceptDeclaration::new('Finish', 'What a surface is covered with.'),
        KindDeclaration::of('Tiles', 'Laid in a grid.', 'Finish'),
        KindDeclaration::of('Kitchen', 'Where meals are made.', 'Room', resolves: [Resolves::to('Flooring', 'Tiles')]),
        ConceptDeclaration::extending('Bedroom', 'Where people sleep.', 'Room'),
    ] as $declaration
) {
    $registry->put(DeclarationFile::of('Homes', $declaration));
}

$checker = Checker::over($registry);

foreach ($checker->statuses() as $status) {
    echo $status, PHP_EOL;
}

$users = $checker->usersOf(DeclarationId::of('Homes', 'Room'));
echo 'Users of Room: ', implode(', ', array_map(static fn (DeclarationId $id): string => $id->name(), $users)), PHP_EOL;

// Nothing is written: each impact is the headspace built twice and compared.
$proposals = ['Room without Flooring' => $room(), 'Room with Lighting too' => $room($flooring, $lighting)];

foreach ($proposals as $saving => $file) {
    echo PHP_EOL, 'Saving ', $saving, ':', PHP_EOL;

    foreach ($checker->impactOfSaving($file) as $change) {
        echo '  ', $change->after(), match (true) {
            $change->breaks() => ' (breaks)',
            $change->mends() => ' (mends)',
            $change->newlyOpen() !== [] => ' (newly open at ' . implode(', ', $change->newlyOpen()) . ')',
            default => '',
        }, PHP_EOL;
    }
}

$remove = static function (string $path) use (&$remove): void {
    foreach (glob($path . '/*') ?: [] as $entry) {
        is_dir($entry) ? $remove($entry) : unlink($entry);
    }

    rmdir($path);
};
$remove($root);

It prints:

Bedroom in Homes is open at Flooring
Finish in Homes is complete
Kitchen in Homes is complete
Room in Homes is open at Flooring
Tiles in Homes is complete
Users of Room: Bedroom, Kitchen

Saving Room without Flooring:
  Bedroom in Homes is complete
  Kitchen in Homes is faulty: No requirement is named Flooring (breaks)
  Room in Homes is complete

Saving Room with Lighting too:
  Bedroom in Homes is open at Flooring, Lighting (newly open at Lighting)
  Kitchen in Homes is faulty: Kind Kitchen leaves requirements unmet: Lighting (breaks)
  Room in Homes is open at Flooring, Lighting (newly open at Lighting)

Bedroom and Room change without breaking: one fewer place, or one more, is left open.

A dinner party, projected

Friday with the Hendersons is a dinner party with its starter and main chosen: projected as a document and a diagram, then written as folders and listed from the disk. A garden with nothing chosen yet is projected as its outline, open places and all. This is examples/projections.php, Barncept's own in Concept Manager's words, which the tests run.

use Rmb32\ConceptManager\Concept\Declaration\ConceptDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\KindDeclaration;
use Rmb32\ConceptManager\Concept\Declaration\Provides;
use Rmb32\ConceptManager\Concept\Declaration\Requires;
use Rmb32\ConceptManager\Concept\Declaration\Resolves;
use Rmb32\ConceptManager\Concept\Headspace\Headspace;
use Rmb32\ConceptManager\Concept\Instance\Instance;
use Rmb32\ConceptManager\Projection\FileProjection;
use Rmb32\ConceptManager\Projection\FolderProjection;
use Rmb32\ConceptManager\Projection\ProjectionKind;
use Rmb32\ConceptManager\Projection\Projectors;
use Rmb32\ConceptManager\Projection\Writer;

$dinners = Headspace::build(
    'Dinner parties',
    'Evening meals for guests.',
    ConceptDeclaration::new('Course', 'One dish of the meal.'),
    ConceptDeclaration::new(
        'Dinner party',
        'An evening meal for guests.',
        requires: [
            Requires::one('Starter', 'Served first.', 'Course'),
            Requires::one('Main', 'The centre of the meal.', 'Course'),
        ],
        provides: [Provides::part('Shopping list', 'Bought the day before.')],
    ),
    KindDeclaration::of('Tomato soup', 'Made the day before.', 'Course'),
    KindDeclaration::of('Mushroom risotto', 'Everyone eats it.', 'Course'),
    KindDeclaration::of('Friday with the Hendersons', 'Six guests.', 'Dinner party', resolves: [
        Resolves::to('Starter', 'Tomato soup'),
        Resolves::to('Main', 'Mushroom risotto'),
    ]),
);
$homes = Headspace::build(
    'Homes',
    'Houses and the ground around them.',
    ConceptDeclaration::new('Boundary', 'What marks where ground ends.'),
    ConceptDeclaration::new('Planting', 'What grows.'),
    ConceptDeclaration::new(
        'Garden',
        'Ground outside the house.',
        requires: [
            Requires::one('Boundary', 'Where the garden ends.', 'Boundary'),
            Requires::one('Planting', 'What grows in the beds.', 'Planting'),
        ],
        provides: [Provides::part('Shed', 'Where the tools are kept.')],
    ),
);

$friday = Instance::of($dinners->kind('Friday with the Hendersons'));
$projectors = Projectors::builtIn();

foreach (['markdown', 'mermaid'] as $kind) {
    $file = $projectors->get(ProjectionKind::of($kind))->project($friday);

    if ($file instanceof FileProjection) {
        echo '--- ', $file->mediaType(), PHP_EOL, $file->content();
    }
}

$folders = $projectors->get(ProjectionKind::of('folder'))->project($friday);
$root = sys_get_temp_dir() . '/concept-manager-projections-' . getmypid();

if ($folders instanceof FolderProjection) {
    new Writer()->write($folders, $root . '/' . $folders->root()->name());
    echo '--- folders', PHP_EOL;

    $entries = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS));
    $paths = [];

    foreach ($entries as $entry) {
        /** @var SplFileInfo $entry */
        $paths[] = substr($entry->getPathname(), strlen($root) + 1);
    }

    sort($paths);
    echo implode(PHP_EOL, $paths), PHP_EOL;
}

// An outline shows what is still open.
$garden = Instance::of($homes->concept('Garden'));

foreach (['text', 'markdown'] as $kind) {
    $file = $projectors->get(ProjectionKind::of($kind))->project($garden);

    if ($file instanceof FileProjection) {
        echo '--- outline, ', $file->mediaType(), PHP_EOL, $file->content();
    }
}

$remove = static function (string $path) use (&$remove): void {
    foreach (glob($path . '/*') ?: [] as $entry) {
        is_dir($entry) ? $remove($entry) : unlink($entry);
    }

    rmdir($path);
};
$remove($root);

It prints:

--- text/markdown
# Friday with the Hendersons

Six guests.

## Shopping list

Bought the day before.

## Starter: Tomato soup

Made the day before.

## Main: Mushroom risotto

Everyone eats it.
--- text/vnd.mermaid
flowchart TD
    n1["Friday with the Hendersons"]
    n2["Shopping list"]
    n1 --> n2
    n3["Tomato soup"]
    n1 -->|"Starter"| n3
    n4["Mushroom risotto"]
    n1 -->|"Main"| n4
--- folders
Friday with the Hendersons/Mushroom risotto/README.md
Friday with the Hendersons/README.md
Friday with the Hendersons/Shopping list/README.md
Friday with the Hendersons/Tomato soup/README.md
--- outline, text/plain
Garden
|
+--Shed
+--Boundary (open)
+--Planting (open)
--- outline, text/markdown
# Garden

Ground outside the house.

- **Boundary** (open) — Where the garden ends.
- **Planting** (open) — What grows in the beds.

## Shed

Where the tools are kept.

Not yet

More than one parent, renaming, adding a requirement inside an inherited part, and conditions on a tree, such as "a kitchen somewhere inside".