]> Lady’s Gitweb - Etiquette/commitdiff
Use configurable metadata for model
authorLady <redacted>
Wed, 14 Jun 2023 02:48:09 +0000 (19:48 -0700)
committerLady <redacted>
Wed, 14 Jun 2023 05:00:30 +0000 (22:00 -0700)
Instead of hardcoding properties and methods, use a schema to generate
them. This makes the code significantly dry·er at the cost of some
slightly obtuse metaprogramming.

The schema format is somewhat minimal and makes some assumptions; this
is not intended as a generalist O·W·L processor or anything of the
sort.

Previously, `TagSystem::Tag` returned a bound class constructor. With
this commit, it instead returns a (manually‐defined) constructor
function which effectively does the partial application in its function
body. Each constructor has its own unique prototype object (inheriting
from `Tag.prototype`) and its own static methods (bound forms of `Tag`
static methods). Constructors themselves inherit from
`Function.prototype` (for now).

model.js
model.test.js
schema.js [new file with mode: 0644]

index ba71361890494f3e3c7aaa4369af83d50b8e8440..3f0b6adea4b7cd3281070a62b314df26be673f08 100644 (file)
--- a/model.js
+++ b/model.js
@@ -9,118 +9,7 @@
 
 import { Storage } from "./memory.js";
 import { taggingDiscoveryContext } from "./names.js";
-
-// TODO: Move these somewhere else and allow for modification before
-// they are used, freezing them only once tags actually start being
-// constructed (probably on first call to the `TagSystem` initializer
-// for convenience).
-//
-// Or else make them properties of the tag system itself and ∼fully
-// modifiable.
-
-/**
- * Tag kinds which denote entity tags.
- *
- * ※ This object is not exposed.
- */
-const ENTITY_TAG_KINDS = new Set([
-  "EntityTag",
-  "CharacterTag",
-  "InanimateEntityTag",
-]);
-
-/**
- * Tag kinds which denote relationship tags.
- *
- * ※ This object is not exposed.
- */
-const RELATIONSHIP_TAG_KINDS = new Set([
-  "RelationshipTag",
-  "FamilialRelationship Tag",
-  "FriendshipTag",
-  "RivalryTag",
-  "RomanticRelationshipTag",
-  "SexualRelationshipTag",
-]);
-
-/**
- * Tag kinds which denote setting tags.
- *
- * ※ This object is not exposed.
- */
-const SETTING_TAG_KINDS = new Set([
-  "SettingTag",
-  "LocationTag",
-  "TimePeriodTag",
-  "UniverseTag",
-]);
-
-/**
- * Tag kinds which denote conceptual tags.
- *
- * ※ This object is not exposed.
- */
-const CONCEPTUAL_TAG_KINDS = new Set(function* () {
-  yield "ConceptualTag";
-  yield* RELATIONSHIP_TAG_KINDS;
-}());
-
-/**
- * All recognized tag kinds.
- *
- * ※ This object is not exposed.
- */
-const TAG_KINDS = new Set(function* () {
-  yield "Tag";
-  yield "CanonTag";
-  yield* CONCEPTUAL_TAG_KINDS;
-  yield* ENTITY_TAG_KINDS;
-  yield "GenreTag";
-  yield* SETTING_TAG_KINDS;
-}());
-
-/**
- * Tag kinds which can be in canon.
- *
- * ※ This object is not exposed.
- */
-const HAS_IN_CANON = new Set(function* () {
-  yield* ENTITY_TAG_KINDS;
-  yield* SETTING_TAG_KINDS;
-}());
-
-/**
- * Tag kinds which can be involved in relationship tags.
- *
- * ※ This object is not exposed.
- */
-const INVOLVABLE_IN_RELATIONSHIP = new Set(function* () {
-  yield "CharacterTag";
-  yield* RELATIONSHIP_TAG_KINDS;
-}());
-
-/**
- * Properties which take literal values instead of identifiers.
- *
- * These are the label terms.
- */
-const LITERAL_TERMS = new Set([
-  "prefLabel",
-  "altLabel",
-  "hiddenLabel",
-]);
-
-/**
- * Properties to skip when diffing.
- *
- * These are all inverses of properties included in diffs and cannot be
- * changed manually.
- */
-const SKIP_IN_DIFF = new Set([
-  "hasInCanon",
-  "isIncludedIn",
-  "narrower",
-]);
+import schema from "./schema.js";
 
 /**
  * A tag.
@@ -143,6 +32,9 @@ class Tag {
   /** The `Storage` managed by this `Tag`’s `TagSystem`. */
   #storage;
 
+  /** The schema in use for this `Tag`. */
+  #schema;
+
   /**
    * The 30‐bit W·R·M·G base32 identifier with leading checksum which
    * has been assigned to this `Tag`.
@@ -166,6 +58,110 @@ class Tag {
   /** The current (modified) data associated with this `Tag`. */
   #data = tagData();
 
+  /**
+   * Adds the provided label(s) to this `Tag` as the provided
+   * predicate, then returns this `Tag`.
+   */
+  #addLabel(predicate, ...labels) {
+    const values = this.#data[predicate];
+    for (const $ of labels) {
+      // Iterate over each provided label and attempt to add it.
+      const literal = langString($);
+      values.add(literal);
+    }
+    return this;
+  }
+
+  /**
+   * Adds the provided tags to the list of tags that this `Tag` is
+   * related to by the provided predicate, then returns this `Tag`.
+   *
+   * Arguments may be string identifiers or objects with an
+   * `.identifier` property.
+   */
+  #addTag(predicate, ...tags) {
+    const storage = this.#storage;
+    const values = this.#data[predicate];
+    for (const $ of tags) {
+      // Iterate over each tag and attempt to state the predicate.
+      const identifier = toIdentifier($);
+      if (identifier == null) {
+        // ☡ The current tag has no identifier.
+        throw new TypeError(
+          `Cannot state ${predicate} of Tag: Identifier must not be nullish.`,
+        );
+      } else if (values.has(identifier)) {
+        // Short‐circuit: The identifier has already been stated with
+        // this predicate.
+        /* do nothing */
+      } else {
+        // The current tag has an identifier, but it hasn’t been stated
+        // with this predicate yet.
+        const tag = storage.get(identifier);
+        if (tag == null) {
+          // ☡ The current tag has not been persisted to this `Tag`’s
+          // storage.
+          throw new RangeError(
+            `Cannot state ${predicate} of Tag: Identifier is not persisted: ${identifier}.`,
+          );
+        } else if (!this.#isTagInStorage(tag)) {
+          // ☡ The current tag is not a tag in the correct tag system.
+          throw new TypeError(
+            `Cannot state ${predicate} of Tag: Tags must be from the same Tag System, but got: ${identifier}.`,
+          );
+        } else if (
+          !isObjectPredicateOK(
+            this.#schema,
+            this.#kind,
+            predicate,
+            tag.#kind,
+          )
+        ) {
+          // ☡ This tag and the current tag form an invalid pair for
+          // this predicate.
+          throw new TypeError(
+            `Cannot state ${predicate} of Tag: Not valid for domain and range: ${this.#kind}, ${tag.#kind}.`,
+          );
+        } else {
+          // The current tag is a tag in the correct tag system; add
+          // its identifier.
+          values.add(identifier);
+        }
+      }
+    }
+    return this;
+  }
+
+  /**
+   * Removes the provided string label(s) from this `Tag` as the
+   * provided predicate, then returns this `Tag`.
+   */
+  #deleteLabel(predicate, ...labels) {
+    const values = this.#data[predicate];
+    for (const $ of labels) {
+      // Iterate over each provided label and attempt to remove it.
+      const literal = langString($);
+      values.delete(literal);
+    }
+    return this;
+  }
+
+  /**
+   * Removes the provided tags from the list of tags that this `Tag` is
+   * related to by the provided predicate, then returns this `Tag`.
+   *
+   * Arguments may be string identifiers or objects with an
+   * `.identifier` property.
+   */
+  #deleteTag(predicate, ...tags) {
+    const values = this.#data[predicate];
+    for (const $ of tags) {
+      // Iterate over the provided tags and delete them.
+      values.delete(toIdentifier($));
+    }
+    return this;
+  }
+
   /**
    * Returns whether or not the provided value is a tag which shares a
    * storage with this tag.
@@ -183,6 +179,94 @@ class Tag {
     }
   }
 
+  /**
+   * Yields the labels of this `Tag` according to the provided
+   * predicate.
+   */
+  *#yieldLabels(predicate) {
+    yield* this.#data[predicate];
+  }
+
+  /**
+   * Yields the tags that this `Tag` is related to by the provided
+   * predicate.
+   */
+  *#yieldTags(predicate) {
+    const storage = this.#storage;
+    for (const identifier of this.#data[predicate]) {
+      // Iterate over the tags in this predicate and yield them if
+      // possible.
+      const tag = storage.get(identifier);
+      if (
+        !this.#isTagInStorage(tag) || !isObjectPredicateOK(
+          this.#schema,
+          this.#kind,
+          predicate,
+          tag.#kind,
+        )
+      ) {
+        // The tag no longer appears in storage or is not compatible;
+        // perhaps it was deleted.
+        /* do nothing */
+      } else {
+        // The tag exists and is constructable from storage.
+        yield tag;
+      }
+    }
+  }
+
+  /**
+   * Yields the tags that this `Tag` is related to by the provided
+   * predicate, figured transitively.
+   */
+  *#yieldTransitiveTags(transitivePredicate, basePredicate) {
+    const storage = this.#storage;
+    const encountered = new Set();
+    let pending = new Set(this.#data[basePredicate]);
+    while (pending.size > 0) {
+      // Loop until all tags of the predicate have been encountered.
+      const processing = pending;
+      pending = new Set();
+      for (const identifier of processing) {
+        // Iterate over the tags and yield them if possible.
+        if (!encountered.has(identifier)) {
+          // The tag has not been encountered before.
+          encountered.add(identifier);
+          const tag = storage.get(identifier);
+          if (
+            !this.#isTagInStorage(tag) || !isObjectPredicateOK(
+              this.#schema,
+              this.#kind,
+              transitivePredicate,
+              tag.#kind,
+            )
+          ) {
+            // The tag no longer appears in storage or is not
+            // compatible; perhaps it was deleted.
+            /* do nothing */
+          } else {
+            // The tag exists and is constructable from storage.
+            yield tag;
+            for (const transitive of tag.#data[basePredicate]) {
+              // Iterate over the nested tags of the current tag and
+              // add them to pending as needed.
+              if (!encountered.has(transitive)) {
+                // The nested tag has not been encountered yet.
+                pending.add(transitive);
+              } else {
+                // The nested tag has already been encountered.
+                /* do nothing */
+              }
+            }
+          }
+        } else {
+          // The tag has already been encountered.
+          /* do nothing */
+        }
+      }
+    }
+  }
+
   /**
    * Constructs a new `Tag` of the provided kind and with the provided
    * preferred label.
@@ -194,22 +278,147 @@ class Tag {
    *
    * ☡ This constructor throws if the provided kind is not supported.
    */
-  constructor(system, storage, kind = "Tag", prefLabel = "") {
+  constructor(system, storage, schema, kind = "Tag", prefLabel = "") {
+    this.#system = system;
+    this.#storage = storage;
+    this.#schema = schema;
     const kindString = `${kind}`;
-    if (TAG_KINDS.has(kindString)) {
-      // The provided kind is one of the recognized tag kinds.
-      this.#system = system;
-      this.#storage = storage;
-      this.#kind = kindString;
-      this.#data.prefLabel = prefLabel;
-    } else {
+    if (!(kindString in schema.classes)) {
       // The provided kind is not supported.
       throw new RangeError(
         `Cannot construct Tag: Unrecognized kind: ${kind}.`,
       );
+    } else {
+      // The provided kind is one of the recognized tag kinds.
+      this.#kind = kindString;
+      this.#data.prefLabel = prefLabel;
     }
   }
 
+  /**
+   * Returns a new `Tag` constructor for the provided system, storage,
+   * schema, created with an appropriate prototype for the properties
+   * so defined.
+   *
+   * ※ This function is not exposed.
+   */
+  static For(system, storage, schema) {
+    const {
+      objectProperties,
+      transitiveProperties,
+      dataProperties,
+    } = schema;
+    const constructor = function (...$s) {
+      return Reflect.construct(
+        Tag,
+        [system, storage, schema, ...$s],
+        new.target,
+      );
+    };
+    Object.defineProperties(constructor, {
+      prototype: {
+        configurable: false,
+        enumerable: false,
+        value: Object.create(
+          Tag.prototype,
+          Object.fromEntries(Array.from(
+            function* () {
+              for (const key in objectProperties) {
+                const {
+                  inverseOf,
+                  subPropertyOf,
+                } = objectProperties[key];
+                if (key in transitiveProperties) {
+                  // Transitive property methods are added by their
+                  // nontransitive subproperties.
+                  /* do nothing */
+                } else {
+                  yield [`${key}Tags`, function* () {
+                    yield* this.#yieldTags(key);
+                  }];
+                  if (inverseOf == null) {
+                    const cased = key[0].toUpperCase() +
+                      key.substring(1);
+                    yield [`add${cased}Tag`, function (...tags) {
+                      return this.#addTag(key, ...tags);
+                    }];
+                    yield [`delete${cased}Tag`, function (...tags) {
+                      return this.#deleteTag(key, ...tags);
+                    }];
+                  } else {
+                    /* do nothing */
+                  }
+                  if (
+                    subPropertyOf != null &&
+                    subPropertyOf in transitiveProperties
+                  ) {
+                    yield [`${subPropertyOf}Tags`, function* () {
+                      yield* this.#yieldTransitiveTags(
+                        subPropertyOf,
+                        key,
+                      );
+                    }];
+                  } else {
+                    /* do nothing */
+                  }
+                }
+              }
+              for (const key in dataProperties) {
+                if (key != "prefLabel") {
+                  const cased = key[0].toUpperCase() +
+                    key.substring(1);
+                  yield [`${key}s`, function* () {
+                    yield* this.#yieldLabels(key);
+                  }];
+                  yield [`add${cased}`, function (...labels) {
+                    return this.#addLabel(key, ...labels);
+                  }];
+                  yield [`delete${cased}`, function (...labels) {
+                    return this.#deleteLabel(key, ...labels);
+                  }];
+                } else {
+                  /* do nothing */
+                }
+              }
+            }(),
+            ([key, value]) => [key, {
+              configurable: true,
+              enumerable: false,
+              value: Object.defineProperty(value, "name", {
+                value: key,
+              }),
+              writable: true,
+            }],
+          )),
+        ),
+        writable: false,
+      },
+    });
+    return Object.defineProperties(
+      constructor,
+      Object.fromEntries([
+        ["name", { value: "TagSystem::Tag" }],
+        ...[
+          "all",
+          "fromIRI",
+          "fromIdentifier",
+          "fromTagURI",
+          "identifiers",
+          Storage.toInstance,
+        ].map((key) => [key, {
+          configurable: true,
+          enumerable: false,
+          value: Object.defineProperty(
+            Tag[key].bind(constructor, system, storage),
+            "name",
+            { value: String(key) },
+          ),
+          writable: true,
+        }]),
+      ]),
+    );
+  }
+
   /**
    * Yields the tags in the `TagSystem` associated with this
    * constructor.
@@ -331,569 +540,67 @@ class Tag {
   }
 
   /**
-   * Yields the tag identifiers in the `TagSystem` associated with this
-   * constructor.
-   *
-   * ※ The first two arguments of this function are bound when
-   * generating the value of `TagSystem::Tag`. It isn’t possible to
-   * access this function in its unbound form from outside this module.
-   */
-  static *identifiers(system, storage) {
-    for (const [identifier, instance] of storage.entries()) {
-      // Iterate over the entries and yield the ones which are `Tag`s
-      // in this `TagSystem`.
-      if (Tag.getSystem(instance) == system) {
-        // The current instance is a `Tag` in this `TagSystem`.
-        yield identifier;
-      } else {
-        // The current instance is not a `Tag` in this `TagSystem`.
-        /* do nothing */
-      }
-    }
-  }
-
-  /**
-   * Returns a new `Tag` constructed from the provided data and with
-   * the provided identifier.
-   *
-   * ※ This function will not work if called directly from `Tag` (and
-   * nor is it available *to* be called as such from outside this
-   * module). It must be called from a `TagSystem::Tag` bound
-   * constructor.
-   *
-   * ※ This function is not really intended for public usage.
-   */
-  static [Storage.toInstance](_system, _storage, data, identifier) {
-    const tag = new this(data.kind);
-    tag.#identifier = `${identifier}`;
-    tag.#persistedData = tagData(data);
-    tag.#data = tagData(data);
-    return tag;
-  }
-
-  static {
-    // Overwrite the default `::constructor` method to instead give the
-    // actual (bound) constructor which was used to generate a given
-    // `Tag`.
-    Object.defineProperties(this.prototype, {
-      constructor: {
-        configurable: true,
-        enumerable: false,
-        get() {
-          // All `Tag`s are constructed via the `.Tag` constructor
-          // available in their `TagSystem`; return it.
-          return this.#system.Tag;
-        },
-        set: undefined,
-      },
-    });
-  }
-
-  /**
-   * Adds the provided label(s) to this `Tag` as alternate labels, then
-   * returns this `Tag`.
-   */
-  addAltLabel(...labels) {
-    const altLabels = this.#data.altLabel;
-    let objectLabels = null; // initialized on first use
-    for (const $ of labels) {
-      // Iterate over each provided label and attempt to add it.
-      const literal = langString($);
-      if (Object(literal) === literal) {
-        // The current label is a language‐tagged string.
-        objectLabels ??= [...function* () {
-          for (const altLabel of altLabels) {
-            // Iterate over the existing labels and yield the
-            // language‐tagged strings.
-            if (Object(altLabel) === altLabel) {
-              // The current existing label is a language‐tagged
-              // string.
-              yield altLabel;
-            } else {
-              // The current existing label is not a language‐tagged
-              // string.
-              /* do nothing */
-            }
-          }
-        }()];
-        if (
-          objectLabels.some((objectLabel) =>
-            objectLabel["@value"] == literal["@value"] &&
-            objectLabel["@language"] == literal["@language"]
-          )
-        ) {
-          // There is a match with the current label in the existing
-          // labels.
-          /* do nothing */
-        } else {
-          // There is no match and this label must be added.
-          altLabels.add(literal);
-          objectLabels.push(literal);
-        }
-      } else {
-        // The current label is a simple string.
-        altLabels.add(literal);
-      }
-    }
-    return this;
-  }
-
-  /**
-   * Adds the provided tags to the list of tags that this `Tag` is
-   * narrower than, then returns this `Tag`.
-   *
-   * Arguments may be string identifiers or objects with an
-   * `.identifier` property.
-   */
-  addBroaderTag(...tags) {
-    const storage = this.#storage;
-    const broader = this.#data.broader;
-    for (const $ of tags) {
-      // Iterate over each tag and attempt to set it as broader than
-      // this `Tag`.
-      const identifier = toIdentifier($);
-      if (identifier == null) {
-        // ☡ The current tag has no identifier.
-        throw new TypeError(
-          "Cannot assign broader to Tag: Identifier must not be nullish.",
-        );
-      } else if (broader.has(identifier)) {
-        // Short‐circuit: The identifier is already something this
-        // `Tag` is narrower than.
-        /* do nothing */
-      } else {
-        // The current tag has an identifier.
-        const tag = storage.get(identifier);
-        if (tag == null) {
-          // ☡ The current tag has not been persisted to this `Tag`’s
-          // storage.
-          throw new RangeError(
-            `Cannot assign broader to Tag: Identifier is not persisted: ${identifier}.`,
-          );
-        } else if (!this.#isTagInStorage(tag)) {
-          // ☡ The current tag is not a tag in the correct tag system.
-          throw new TypeError(
-            `Cannot assign broader to Tag: Tags must be from the same Tag System, but got: ${identifier}.`,
-          );
-        } else {
-          // The current tag is a tag in the correct tag system; add
-          // its identifier.
-          broader.add(identifier);
-        }
-      }
-    }
-    return this;
-  }
-
-  /**
-   * Adds the provided label(s) to this `Tag` as hidden labels, then
-   * returns this `Tag`.
-   */
-  addHiddenLabel(...labels) {
-    const hiddenLabels = this.#data.hiddenLabel;
-    let objectLabels = null; // initialized on first use
-    for (const $ of labels) {
-      // Iterate over each provided label and attempt to add it.
-      const literal = langString($);
-      if (Object(literal) === literal) {
-        // The current label is a language‐tagged string.
-        objectLabels ??= [...function* () {
-          for (const hiddenLabel of hiddenLabels) {
-            // Iterate over the existing labels and yield the
-            // language‐tagged strings.
-            if (Object(hiddenLabel) === hiddenLabel) {
-              // The current existing label is a language‐tagged
-              // string.
-              yield hiddenLabel;
-            } else {
-              // The current existing label is not a language‐tagged
-              // string.
-              /* do nothing */
-            }
-          }
-        }()];
-        if (
-          objectLabels.some((objectLabel) =>
-            objectLabel["@value"] == literal["@value"] &&
-            objectLabel["@language"] == literal["@language"]
-          )
-        ) {
-          // There is a match with the current label in the existing
-          // labels.
-          /* do nothing */
-        } else {
-          // There is no match and this label must be added.
-          hiddenLabels.add(literal);
-          objectLabels.push(literal);
-        }
-      } else {
-        // The current label is a simple string.
-        hiddenLabels.add(literal);
-      }
-    }
-    return this;
-  }
-
-  /**
-   * Adds the provided tags to the list of tags that this `Tag` is in
-   * canon with, then returns this `Tag`.
-   *
-   * Arguments may be string identifiers or objects with an
-   * `.identifier` property.
-   *
-   * ☡ This method will throw if a provided argument does not indicate
-   * a canon tag, or if this `Tag` is not of a kind which can be placed
-   * in canon.
-   */
-  addInCanonTag(...tags) {
-    const storage = this.#storage;
-    const kind = this.#kind;
-    const inCanon = this.#data.inCanon;
-    if (!HAS_IN_CANON.has(kind)) {
-      // ☡ This is not an entity tag, setting tag, or recognized
-      // subclass.
-      throw new TypeError(
-        `Cannot put Tag in canon: Incorrect Tag type: ${kind}.`,
-      );
-    } else {
-      // This has a kind which can be placed in canon.
-      for (const $ of tags) {
-        // Iterate over each tag and attempt to set this `Tag` in canon
-        // of it.
-        const identifier = toIdentifier($);
-        if (identifier == null) {
-          // ☡ The current tag has no identifier.
-          throw new TypeError(
-            "Cannot put Tag in canon: Identifier must not be nullish.",
-          );
-        } else if (inCanon.has(identifier)) {
-          // Short‐circuit: The identifier is already something this
-          // `Tag` is in canon of.
-          /* do nothing */
-        } else {
-          // The current tag has an identifier.
-          const tag = storage.get(identifier);
-          if (tag == null) {
-            // ☡ The current tag has not been persisted to this `Tag`’s
-            // storage.
-            throw new RangeError(
-              `Cannot put Tag in canon: Identifier is not persisted: ${identifier}.`,
-            );
-          } else if (
-            // ※ If the first check succeeds, then the current tag
-            // must have `Tag` private class features.
-            !this.#isTagInStorage(tag) || tag.#kind != "CanonTag"
-          ) {
-            // ☡ The current tag is not a canon tag in the correct
-            // tag system.
-            throw new TypeError(
-              `Cannot put Tag in canon: Tags can only be in Canon Tags from the same Tag System, but got: ${identifier}.`,
-            );
-          } else {
-            // The current tag is a canon tag in the correct tag
-            // system; add its identifier.
-            inCanon.add(identifier);
-          }
-        }
-      }
-    }
-    return this;
-  }
-
-  /**
-   * Adds the provided tags to the list of tags that this `Tag`
-   * involves, then returns this `Tag`.
-   *
-   * Arguments may be string identifiers or objects with an
-   * `.identifier` property.
-   *
-   * ☡ This method will throw if this `Tag` is not a conceptual tag, or
-   * if this `Tag` is a relationship tag and a provided argument does
-   * not indicate a character or relationship tag.
-   */
-  addInvolvesTag(...tags) {
-    const storage = this.#storage;
-    const kind = this.#kind;
-    const involves = this.#data.involves;
-    if (!CONCEPTUAL_TAG_KINDS.has(kind)) {
-      // ☡ This is not a conceptual tag or recognized subclass.
-      throw new TypeError(
-        `Cannot involve Tag: Incorrect Tag type: ${kind}.`,
-      );
-    } else {
-      // This is a conceptual tag.
-      for (const $ of tags) {
-        // Iterate over each tag and attempt to set this `Tag` as
-        // involving it.
-        const identifier = toIdentifier($);
-        if (identifier == null) {
-          // ☡ The current tag has no identifier.
-          throw new TypeError(
-            "Cannot involve Tag: Identifier must not be nullish.",
-          );
-        } else if (involves.has(identifier)) {
-          // Short‐circuit: The identifier is already something this
-          // `Tag` involves.
-          /* do nothing */
-        } else {
-          // The current tag has an identifier.
-          const tag = storage.get(identifier);
-          if (tag == null) {
-            // ☡ The current tag has not been persisted to this `Tag`’s
-            // storage.
-            throw new RangeError(
-              `Cannot involve Tag: Identifier is not persisted: ${identifier}.`,
-            );
-          } else if (
-            // ※ If the first check succeeds, then the current tag
-            // must have `Tag` private class features.
-            !this.#isTagInStorage(tag) ||
-            RELATIONSHIP_TAG_KINDS.has(kind) &&
-              !INVOLVABLE_IN_RELATIONSHIP.has(tag.#kind)
-          ) {
-            // ☡ The current tag is in the correct tag system and
-            // includable.
-            throw new TypeError(
-              `Cannot involve Tag: Tags must be the same Tag System and involvable, but got: ${identifier}.`,
-            );
-          } else {
-            // The current tag is an involvable tag in the correct tag
-            // system; add its identifier.
-            involves.add(identifier);
-          }
-        }
-      }
-    }
-    return this;
-  }
-
-  /** Yields the alternative labels of this `Tag`. */
-  *altLabels() {
-    yield* this.#data.altLabel;
-  }
-
-  /** Returns the authority (domain) name for this `Tag`. */
-  get authorityName() {
-    return this.#system.authorityName;
-  }
-
-  /** Yields `Tag`s which are broader than this `Tag`. */
-  *broaderTags() {
-    const storage = this.#storage;
-    for (const identifier of this.#data.broader) {
-      // Iterate over the broader tags and yield them if possible.
-      const tag = storage.get(identifier);
-      if (!this.#isTagInStorage(tag)) {
-        // The broader tag no longer appears in storage; perhaps it was
-        // deleted.
-        /* do nothing */
-      } else {
-        // The broader tag exists and is constructable from storage.
-        yield tag;
-      }
-    }
-  }
-
-  /** Yields `Tag`s which are broader than this `Tag`, transitively. */
-  *broaderTransitiveTags() {
-    const storage = this.#storage;
-    const encountered = new Set();
-    let pending = new Set(this.#data.broader);
-    while (pending.size > 0) {
-      // Loop until all broader tags have been encountered.
-      const processing = pending;
-      pending = new Set();
-      for (const identifier of processing) {
-        // Iterate over the broader tags and yield them if possible.
-        if (!encountered.has(identifier)) {
-          // The broader tag has not been encountered before.
-          encountered.add(identifier);
-          const tag = storage.get(identifier);
-          if (!this.#isTagInStorage(tag)) {
-            // The broader tag no longer appears in storage; perhaps it
-            // was deleted.
-            /* do nothing */
-          } else {
-            // The broader tag exists and is constructable from
-            // storage.
-            yield tag;
-            for (const transitive of tag.#data.broader) {
-              // Iterate over the broader tags of the current broader
-              // tag and add them to pending as needed.
-              if (!encountered.has(transitive)) {
-                // The broader broader tag has not been encountered
-                // yet.
-                pending.add(transitive);
-              } else {
-                // The broader broader tag has already been
-                // encountered.
-                /* do nothing */
-              }
-            }
-          }
-        } else {
-          // The broader tag has already been encountered.
-          /* do nothing */
-        }
-      }
-    }
-  }
-
-  /**
-   * Removes the provided string label(s) from this `Tag` as alternate
-   * labels, then returns this `Tag`.
-   */
-  deleteAltLabel(...labels) {
-    const altLabels = this.#data.altLabel;
-    let objectLabels = null; // initialized on first use
-    for (const $ of labels) {
-      // Iterate over each provided label and attempt to remove it.
-      const literal = langString($);
-      if (Object(literal) === literal) {
-        // The current label is a language‐tagged string.
-        objectLabels ??= [...function* () {
-          for (const altLabel of altLabels) {
-            // Iterate over the existing labels and yield the
-            // language‐tagged strings.
-            if (Object(altLabel) === altLabel) {
-              // The current existing label is a language‐tagged
-              // string.
-              yield altLabel;
-            } else {
-              // The current existing label is not a language‐tagged
-              // string.
-              /* do nothing */
-            }
-          }
-        }()];
-        const existing = objectLabels.find((objectLabel) =>
-          objectLabel["@value"] == literal["@value"] &&
-          objectLabel["@language"] == literal["@language"]
-        );
-        altLabels.delete(existing);
-      } else {
-        // The current label is a simple string.
-        altLabels.delete(literal);
-      }
-    }
-    return this;
-  }
-
-  /**
-   * Removes the provided tags from the list of tags that this `Tag` is
-   * narrower than, then returns this `Tag`.
-   *
-   * Arguments may be string identifiers or objects with an
-   * `.identifier` property.
-   */
-  deleteBroaderTag(...tags) {
-    const broader = this.#data.broader;
-    for (const $ of tags) {
-      // Iterate over the provided tags and delete them.
-      broader.delete(toIdentifier($));
-    }
-    return this;
-  }
-
-  /**
-   * Removes the provided string label(s) from this `Tag` as hidden
-   * labels, then returns this `Tag`.
+   * Yields the tag identifiers in the `TagSystem` associated with this
+   * constructor.
+   *
+   * ※ The first two arguments of this function are bound when
+   * generating the value of `TagSystem::Tag`. It isn’t possible to
+   * access this function in its unbound form from outside this module.
    */
-  deleteHiddenLabel(...labels) {
-    const hiddenLabels = this.#data.hiddenLabel;
-    let objectLabels = null; // initialized on first use
-    for (const $ of labels) {
-      // Iterate over each provided label and attempt to remove it.
-      const literal = langString($);
-      if (Object(literal) === literal) {
-        // The current label is a language‐tagged string.
-        objectLabels ??= [...function* () {
-          for (const hiddenLabel of hiddenLabels) {
-            // Iterate over the existing labels and yield the
-            // language‐tagged strings.
-            if (Object(hiddenLabel) === hiddenLabel) {
-              // The current existing label is a language‐tagged
-              // string.
-              yield hiddenLabel;
-            } else {
-              // The current existing label is not a language‐tagged
-              // string.
-              /* do nothing */
-            }
-          }
-        }()];
-        const existing = objectLabels.find((objectLabel) =>
-          objectLabel["@value"] == literal["@value"] &&
-          objectLabel["@language"] == literal["@language"]
-        );
-        hiddenLabels.delete(existing);
+  static *identifiers(system, storage) {
+    for (const [identifier, instance] of storage.entries()) {
+      // Iterate over the entries and yield the ones which are `Tag`s
+      // in this `TagSystem`.
+      if (Tag.getSystem(instance) == system) {
+        // The current instance is a `Tag` in this `TagSystem`.
+        yield identifier;
       } else {
-        // The current label is a simple string.
-        hiddenLabels.delete(literal);
+        // The current instance is not a `Tag` in this `TagSystem`.
+        /* do nothing */
       }
     }
-    return this;
   }
 
   /**
-   * Removes the provided tags from the list of tags that this `Tag` is
-   * in canon with, then returns this `Tag`.
+   * Returns a new `Tag` constructed from the provided data and with
+   * the provided identifier.
    *
-   * Arguments may be string identifiers or objects with an
-   * `.identifier` property.
-   */
-  deleteInCanonTag(...tags) {
-    const inCanon = this.#data.inCanon;
-    for (const $ of tags) {
-      // Iterate over the provided tags and delete them.
-      inCanon.delete(toIdentifier($));
-    }
-    return this;
-  }
-
-  /**
-   * Removes the provided tags from the list of tags that this `Tag`
-   * involves, then returns this `Tag`.
+   * ※ This function will not work if called directly from `Tag` (and
+   * nor is it available *to* be called as such from outside this
+   * module). It must be called from a `TagSystem::Tag` bound
+   * constructor.
    *
-   * Arguments may be string identifiers or objects with an
-   * `.identifier` property.
+   * ※ This function is not really intended for public usage.
    */
-  deleteInvolvesTag(...tags) {
-    const involves = this.#data.involves;
-    for (const $ of tags) {
-      // Iterate over the provided tags and delete them.
-      involves.delete(toIdentifier($));
-    }
-    return this;
+  static [Storage.toInstance](_system, _storage, data, identifier) {
+    const tag = new this(data.kind);
+    tag.#identifier = `${identifier}`;
+    tag.#persistedData = tagData(data);
+    tag.#data = tagData(data);
+    return tag;
   }
 
-  /** Yields `Tag`s that are in canon of this `Tag`. */
-  *hasInCanonTags() {
-    const storage = this.#storage;
-    if (this.#kind == "CanonTag") {
-      // This is a canon tag.
-      for (const identifier of this.#data.hasInCanon) {
-        // Iterate over the tags in canon and yield them if possible.
-        const tag = storage.get(identifier);
-        if (
-          !this.#isTagInStorage(tag) || !HAS_IN_CANON.has(tag.#kind)
-        ) {
-          // The tag in canon no longer appears in storage; perhaps it
-          // was deleted.
-          /* do nothing */
-        } else {
-          // The tag in canon exists and is constructable from storage.
-          yield tag;
-        }
-      }
-    } else {
-      /* do nothing */
-    }
+  static {
+    // Overwrite the default `::constructor` method to instead give the
+    // actual (bound) constructor which was used to generate a given
+    // `Tag`.
+    Object.defineProperties(this.prototype, {
+      constructor: {
+        configurable: true,
+        enumerable: false,
+        get() {
+          // All `Tag`s are constructed via the `.Tag` constructor
+          // available in their `TagSystem`; return it.
+          return this.#system.Tag;
+        },
+        set: undefined,
+      },
+    });
   }
 
-  /** Yields the hidden labels of this `Tag`. */
-  *hiddenLabels() {
-    yield* this.#data.hiddenLabel;
+  /** Returns the authority (domain) name for this `Tag`. */
+  get authorityName() {
+    return this.#system.authorityName;
   }
 
   /** Returns the identifier of this `Tag`. */
@@ -901,80 +608,6 @@ class Tag {
     return this.#identifier;
   }
 
-  /** Yields `Tag`s that this `Tag` is in canon of. */
-  *inCanonTags() {
-    const storage = this.#storage;
-    if (HAS_IN_CANON.has(this.#kind)) {
-      // This tag can be placed in canon.
-      for (const identifier of this.#data.inCanon) {
-        // Iterate over the canon tags and yield them if possible.
-        const tag = storage.get(identifier);
-        if (!this.#isTagInStorage(tag) || tag.#kind != "CanonTag") {
-          // The canon tag no longer appears in storage; perhaps it was
-          // deleted.
-          /* do nothing */
-        } else {
-          // The canon tag exists and is constructable from storage.
-          yield tag;
-        }
-      }
-    } else {
-      // This tag cannot be placed in canon.
-      /* do nothing */
-    }
-  }
-
-  /** Yields `Tag`s which involve this `Tag`. */
-  *involvedInTags() {
-    const storage = this.#storage;
-    for (const identifier of this.#data.involvedIn) {
-      // Iterate over the involving tags and yield them if possible.
-      const tag = storage.get(identifier);
-      const tagKind = tag.#kind;
-      if (
-        !this.#isTagInStorage(tag) ||
-        !CONCEPTUAL_TAG_KINDS.has(tagKind) ||
-        RELATIONSHIP_TAG_KINDS.has(tagKind) &&
-          !INVOLVABLE_IN_RELATIONSHIP.has(this.#kind)
-      ) {
-        // The including tag no longer appears in storage; perhaps it
-        // was deleted.
-        /* do nothing */
-      } else {
-        // The including tag exists and is constructable from storage.
-        yield tag;
-      }
-    }
-  }
-
-  /** Yields `Tag`s that this `Tag` involves. */
-  *involvesTags() {
-    const storage = this.#storage;
-    const kind = this.#kind;
-    if (CONCEPTUAL_TAG_KINDS.has(kind)) {
-      // This tag can involve other tags.
-      for (const identifier of this.#data.involves) {
-        // Iterate over the involved and yield them if possible.
-        const tag = storage.get(identifier);
-        if (
-          !this.#isTagInStorage(tag) ||
-          RELATIONSHIP_TAG_KINDS.has(kind) &&
-            !INVOLVABLE_IN_RELATIONSHIP.has(tag.#kind)
-        ) {
-          // The involved tag no longer appears in storage; perhaps it
-          // was deleted.
-          /* do nothing */
-        } else {
-          // The involved tag exists and is constructable from storage.
-          yield tag;
-        }
-      }
-    } else {
-      // This tag cannot involve other tags.
-      /* do nothing */
-    }
-  }
-
   /** Returns the I·R·I for this `Tag`. */
   get iri() {
     const { identifier, iriSpace } = this;
@@ -991,69 +624,6 @@ class Tag {
     return this.#kind;
   }
 
-  /** Yields `Tag`s which are narrower than this `Tag`. */
-  *narrowerTags() {
-    const storage = this.#storage;
-    for (const identifier of this.#data.narrower) {
-      const tag = storage.get(identifier);
-      if (!this.#isTagInStorage(tag)) {
-        // The narrower tag no longer appears in storage; perhaps it
-        // was deleted.
-        /* do nothing */
-      } else {
-        // The narrower tag exists and is constructable from storage.
-        yield tag;
-      }
-    }
-  }
-
-  /**
-   * Yields `Tag`s which are narrower than this `Tag`, transitively.
-   */
-  *narrowerTransitiveTags() {
-    const storage = this.#storage;
-    const encountered = new Set();
-    let pending = new Set(this.#data.narrower);
-    while (pending.size > 0) {
-      // Loop until all narrower tags have been encountered.
-      const processing = pending;
-      pending = new Set();
-      for (const identifier of processing) {
-        // Iterate over the narrower tags and yield them if possible.
-        if (!encountered.has(identifier)) {
-          // The narrower tag has not been encountered before.
-          encountered.add(identifier);
-          const tag = storage.get(identifier);
-          if (!this.#isTagInStorage(tag)) {
-            // The narrower tag no longer appears in storage; perhaps
-            // it was deleted.
-            /* do nothing */
-          } else {
-            // The narrower tag exists and is constructable from
-            // storage.
-            yield tag;
-            for (const transitive of tag.#data.narrower) {
-              // Iterate over the narrower tags of the current narrower
-              // tag and add them to pending as needed.
-              if (!encountered.has(transitive)) {
-                // The narrower narrower tag has not been encountered
-                // yet.
-                pending.add(transitive);
-              } else {
-                // The narrower narrower tag has already been
-                // encountered.
-                /* do nothing */
-              }
-            }
-          }
-        } else {
-          // The narrower tag has already been encountered.
-          /* do nothing */
-        }
-      }
-    }
-  }
-
   /**
    * Persist this `Tag` to storage and return an ActivityStreams
    * serialization of a Tag Activity representing any changes, or
@@ -1067,19 +637,27 @@ class Tag {
    * as broader than another causes the other tag to reciprocally be
    * marked as narrower.
    *
-   * ※ The inverse terms `hasInCanon`, `isIncludedIn`, and `narrower`
-   * will never appear in the predicates of generated activities.
+   * ※ Inverse object properties will never appear in the predicates
+   * of generated activities.
    */
   persist(silent = false) {
     const system = this.#system;
     const storage = this.#storage;
+    const {
+      objectProperties,
+      transitiveProperties,
+      dataProperties,
+    } = this.#schema;
     const persistedData = this.#persistedData;
     const data = this.#data;
     const diffs = {};
     for (const [key, value] of Object.entries(data)) {
       // Iterate over each entry of the tag data and create a diff
       // with the last persisted information.
-      if (SKIP_IN_DIFF.has(key) || silent && LITERAL_TERMS.has(key)) {
+      if (
+        objectProperties[key]?.inverseOf != null ||
+        silent && key in dataProperties
+      ) {
         // The current property is one which is skipped in diffs.
         //
         // In a silent persist, this includes any literal terms.
@@ -1097,24 +675,13 @@ class Tag {
           };
         } else if (value instanceof Set) {
           // The current property is set‐valued.
-          let values = null; // initialized on first use
           const oldValues = new Set(persisted);
           const newValues = new Set(value);
           for (const existing of persisted) {
             // Iterate over each persisted property and either remove
             // it from the list of new values or add it to the list of
             // removed ones.
-            //
-            // ※ Some special handling is required here for
-            // language‐tagged strings.
-            if (
-              value.has(existing) ||
-              Object(existing) === existing &&
-                (values ??= [...value]).some(($) =>
-                  `${$}` == `${existing}` &&
-                  $.language == existing.language
-                )
-            ) {
+            if (value.has(existing)) {
               // The value is in both the old and new version of the
               // data.
               oldValues.delete(existing);
@@ -1124,10 +691,7 @@ class Tag {
               /* do nothing */
             }
           }
-          diffs[key] = {
-            old: oldValues,
-            new: newValues,
-          };
+          diffs[key] = { old: oldValues, new: newValues };
         } else if (
           `${value}` != `${persisted}` ||
           value.language != persisted.language
@@ -1140,10 +704,7 @@ class Tag {
           };
         } else {
           // The current property did not change.
-          diffs[key] = {
-            old: new Set(),
-            new: new Set(),
-          };
+          diffs[key] = { old: new Set(), new: new Set() };
         }
       }
     }
@@ -1159,38 +720,41 @@ class Tag {
     }
     const persistedIdentifier = this.#identifier;
     this.#persistedData = tagData(data); // cloning here is necessary
-    for (
-      const [term, inverse] of [
-        ["broader", "narrower"],
-        ["inCanon", "hasInCanon"],
-        ["involves", "involvedIn"],
-      ]
-    ) {
-      // Iterate over each term referencing other tags and update the
-      // inverse property on those tags if possible.
-      for (const referencedIdentifier of diffs[term].old) {
-        // Iterate over the removed tags and remove this `Tag` from
-        // their inverse property.
-        const referenced = storage.get(referencedIdentifier);
-        try {
-          // Try removing this `Tag`.
-          referenced.#data[inverse].delete(persistedIdentifier);
-          storage.set(referencedIdentifier, referenced);
-        } catch {
-          // Removal failed, possibly because the other tag was
-          // deleted.
-          /* do nothing */
+    for (const inverse in objectProperties) {
+      // Iterate over each non‐transitive inverse property and update
+      // it based on its inverse on the corresponding tags if possible.
+      const term = objectProperties[inverse].inverseOf;
+      if (term == null || term in transitiveProperties) {
+        // The current property is not the inverse of an non‐transitive
+        // property.
+        /* do nothing */
+      } else {
+        // The current property is the inverse of a non‐transitive
+        // property.
+        for (const referencedIdentifier of diffs[term].old) {
+          // Iterate over the removed tags and remove this `Tag` from
+          // their inverse property.
+          const referenced = storage.get(referencedIdentifier);
+          try {
+            // Try removing this `Tag`.
+            referenced.#data[inverse].delete(persistedIdentifier);
+            storage.set(referencedIdentifier, referenced);
+          } catch {
+            // Removal failed, possibly because the other tag was
+            // deleted.
+            /* do nothing */
+          }
         }
-      }
-      for (const referencedIdentifier of diffs[term].new) {
-        const referenced = storage.get(referencedIdentifier);
-        try {
-          // Try adding this `Tag`.
-          referenced.#data[inverse].add(persistedIdentifier);
-          storage.set(referencedIdentifier, referenced);
-        } catch {
-          // Adding failed, possibly because the other tag was deleted.
-          /* do nothing */
+        for (const referencedIdentifier of diffs[term].new) {
+          const referenced = storage.get(referencedIdentifier);
+          try {
+            // Try adding this `Tag`.
+            referenced.#data[inverse].add(persistedIdentifier);
+            storage.set(referencedIdentifier, referenced);
+          } catch {
+            // Adding failed, possibly because the other tag was deleted.
+            /* do nothing */
+          }
         }
       }
     }
@@ -1232,14 +796,11 @@ class Tag {
             // things as needed.
             for (const oldValue of oldValues) {
               // Iterate over removals and unstate them.
-              if (LITERAL_TERMS.has(term)) {
-                // This is a literal term; push the change wrapped in an
-                // object.
+              if (term in dataProperties) {
+                // This is a literal term; push it.
                 unstates.push({
                   predicate: term,
-                  object: Object(oldValue) === oldValue
-                    ? { ...langString(oldValue) }
-                    : { "@value": `${oldValue}` },
+                  object: { ...oldValue },
                 });
               } else {
                 // This is a named term; attempt to get its I·R·I and
@@ -1266,14 +827,11 @@ class Tag {
             }
             for (const newValue of newValues) {
               // Iterate over additions and state them.
-              if (LITERAL_TERMS.has(term)) {
-                // This is a literal term; push the change wrapped in an
-                // object.
+              if (term in dataProperties) {
+                // This is a literal term; push it.
                 states.push({
                   predicate: term,
-                  object: Object(newValue) === newValue
-                    ? { ...langString(newValue) }
-                    : { "@value": `${newValue}` },
+                  object: { ...newValue },
                 });
               } else {
                 // This is a named term; attempt to get its I·R·I and
@@ -1372,6 +930,228 @@ class Tag {
   }
 }
 
+const {
+  /**
+   * Returns whether the provided schema, subject class, object
+   * property, and object class are consistent.
+   *
+   * This is hardly a full reasoner; it is tuned to the abilites and
+   * needs of this module.
+   */
+  isObjectPredicateOK,
+} = (() => {
+  const cachedClassAndSuperclasses = new WeakMap();
+  const cachedClassRestrictions = new WeakMap();
+  const cachedPredicateRestrictions = new WeakMap();
+
+  const classAndSuperclasses = function* (
+    classes,
+    baseClass,
+    touched = new Set(),
+  ) {
+    if (baseClass == "Thing" || touched.has(baseClass)) {
+      /* do nothing */
+    } else {
+      yield baseClass;
+      touched.add(baseClass);
+      const subClassOf = classes[baseClass]?.subClassOf ?? "Thing";
+      for (
+        const superclass of (
+          typeof subClassOf == "string"
+            ? [subClassOf]
+            : Array.from(subClassOf)
+        ).filter(($) => typeof $ == "string")
+      ) {
+        yield* classAndSuperclasses(classes, superclass, touched);
+      }
+    }
+  };
+
+  const getClassAndSuperclasses = (schema, baseClass) => {
+    const schemaCache = cachedClassAndSuperclasses.get(schema);
+    const cached = schemaCache?.[baseClass];
+    if (cached != null) {
+      return cached;
+    } else {
+      const { classes } = schema;
+      const result = [...classAndSuperclasses(classes, baseClass)];
+      if (schemaCache) {
+        schemaCache[baseClass] = result;
+      } else {
+        cachedClassRestrictions.set(
+          schema,
+          Object.assign(Object.create(null), { [baseClass]: result }),
+        );
+      }
+      return result;
+    }
+  };
+
+  const getClassRestrictions = (schema, domain) => {
+    const schemaCache = cachedClassRestrictions.get(schema);
+    const cached = schemaCache?.[domain];
+    if (cached != null) {
+      return cached;
+    } else {
+      const { classes } = schema;
+      const restrictions = Object.create(null);
+      const subClassOf = classes[domain]?.subClassOf ?? "Thing";
+      for (
+        const superclass of (
+          typeof subClassOf == "string"
+            ? [subClassOf]
+            : Array.from(subClassOf)
+        ).filter(($) => Object($) === $)
+      ) {
+        const { onProperty, allValuesFrom } = superclass;
+        restrictions[onProperty] = processSpace(allValuesFrom);
+      }
+      if (schemaCache) {
+        schemaCache[domain] = restrictions;
+      } else {
+        cachedClassRestrictions.set(
+          schema,
+          Object.assign(Object.create(null), {
+            [domain]: restrictions,
+          }),
+        );
+      }
+      return restrictions;
+    }
+  };
+
+  const getPredicateRestrictions = (schema, predicate) => {
+    const schemaCache = cachedPredicateRestrictions.get(schema);
+    const cached = schemaCache?.[predicate];
+    if (cached != null) {
+      return cached;
+    } else {
+      const { objectProperties } = schema;
+      const restrictions = [
+        ...predicateRestrictions(objectProperties, predicate),
+      ].reduce(
+        (result, { domainIntersection, rangeIntersection }) => {
+          result.domainIntersection.push(...domainIntersection);
+          result.rangeIntersection.push(...rangeIntersection);
+          return result;
+        },
+        Object.assign(Object.create(null), {
+          domainIntersection: [],
+          rangeIntersection: [],
+        }),
+      );
+      if (schemaCache) {
+        schemaCache[predicate] = restrictions;
+      } else {
+        cachedPredicateRestrictions.set(
+          schema,
+          Object.assign(Object.create(null), {
+            [predicate]: restrictions,
+          }),
+        );
+      }
+      return restrictions;
+    }
+  };
+
+  const processSpace = (space) =>
+    Object(space) === space
+      ? "length" in space
+        ? Array.from(
+          space,
+          (subspace) =>
+            Object(subspace) === subspace
+              ? Array.from(subspace.unionOf)
+              : [subspace],
+        )
+        : [Array.from(space.unionOf)]
+      : [[space]];
+
+  const predicateRestrictions = function* (
+    objectProperties,
+    predicate,
+    touched = new Set(),
+  ) {
+    if (predicate == "Property" || touched.has(predicate)) {
+      /* do nothing */
+    } else {
+      const { domain, range, subPropertyOf } =
+        objectProperties[predicate];
+      yield Object.assign(Object.create(null), {
+        domainIntersection: processSpace(domain ?? "Thing"),
+        rangeIntersection: processSpace(range ?? "Thing"),
+      });
+      touched.add(predicate);
+      for (
+        const superproperty of (
+          subPropertyOf == null
+            ? ["Property"]
+            : typeof subPropertyOf == "string"
+            ? [subPropertyOf]
+            : Array.from(subPropertyOf)
+        )
+      ) {
+        yield* predicateRestrictions(
+          objectProperties,
+          superproperty,
+          touched,
+        );
+      }
+    }
+  };
+
+  return {
+    isObjectPredicateOK: (
+      schema,
+      subjectClass,
+      predicate,
+      objectClass,
+    ) => {
+      const { objectProperties } = schema;
+      const predicateDefinition = objectProperties[predicate];
+      const isInverse = "inverseOf" in predicateDefinition;
+      const usedPredicate = isInverse
+        ? predicateDefinition.inverseOf
+        : predicate;
+      const domain = isInverse ? objectClass : subjectClass;
+      const domains = new Set(getClassAndSuperclasses(schema, domain));
+      const ranges = new Set(getClassAndSuperclasses(
+        schema,
+        isInverse ? subjectClass : objectClass,
+      ));
+      const predicateRestrictions = getPredicateRestrictions(
+        schema,
+        usedPredicate,
+      );
+      const { domainIntersection } = predicateRestrictions;
+      const rangeIntersection = [
+        ...predicateRestrictions.rangeIntersection,
+        ...function* () {
+          for (const domain of domains) {
+            const classRestrictionOnPredicate =
+              getClassRestrictions(schema, domain)[usedPredicate];
+            if (classRestrictionOnPredicate != null) {
+              yield* classRestrictionOnPredicate;
+            } else {
+              /* do nothing */
+            }
+          }
+        }(),
+      ];
+      return domainIntersection.every((domainUnion) =>
+        domainUnion.some((domain) =>
+          domain == "Thing" || domains.has(domain)
+        )
+      ) &&
+        rangeIntersection.every((rangeUnion) =>
+          rangeUnion.some((range) =>
+            range == "Thing" || ranges.has(range)
+          )
+        );
+    },
+  };
+})();
+
 const {
   /**
    * Returns the provided value converted into a `String` object with
@@ -1669,47 +1449,7 @@ export class TagSystem {
     } else {
       // No bound constructor has been created yet.
       const storage = this.#storage;
-      const BoundTag = Tag.bind(undefined, this, storage);
-      return this.#Tag = Object.defineProperties(BoundTag, {
-        all: {
-          configurable: true,
-          enumerable: false,
-          value: Tag.all.bind(BoundTag, this, storage),
-          writable: true,
-        },
-        fromIRI: {
-          configurable: true,
-          enumerable: false,
-          value: Tag.fromIRI.bind(BoundTag, this, storage),
-          writable: true,
-        },
-        fromIdentifier: {
-          configurable: true,
-          enumerable: false,
-          value: Tag.fromIdentifier.bind(BoundTag, this, storage),
-          writable: true,
-        },
-        fromTagURI: {
-          configurable: true,
-          enumerable: false,
-          value: Tag.fromTagURI.bind(BoundTag, this, storage),
-          writable: true,
-        },
-        identifiers: {
-          configurable: true,
-          enumerable: false,
-          value: Tag.identifiers.bind(BoundTag, this, storage),
-          writable: true,
-        },
-        name: { value: `${this.tagURI}#${Tag.name}` },
-        prototype: { value: Tag.prototype },
-        [Storage.toInstance]: {
-          configurable: true,
-          enumerable: false,
-          value: Tag[Storage.toInstance].bind(BoundTag, this, storage),
-          writable: true,
-        },
-      });
+      return this.#Tag = Tag.For(this, storage, schema);
     }
   }
 
index 3e9982a7a4fb513495204b5d80608e4d04f2c6a9..74f73daf349f5c068405994f03729d4e36e5fe57 100644 (file)
@@ -140,6 +140,12 @@ describe("TagSystem", () => {
       });
     });
 
+    describe(".For", () => {
+      it("[[Has]] is not present", () => {
+        assertFalse("For" in Tag);
+      });
+    });
+
     describe(".all", () => {
       it("[[Call]] yields all the persisted tags", () => {
         const tags = new Set(function* () {
@@ -411,8 +417,10 @@ describe("TagSystem", () => {
       });
 
       it("[[Call]] throws when this is not a tag which can be placed in canon", () => {
+        const canon = new Tag("CanonTag");
+        canon.persist();
         assertThrows(() => {
-          new Tag().addInCanonTag();
+          new Tag().addInCanonTag(canon);
         });
       });
 
@@ -469,8 +477,10 @@ describe("TagSystem", () => {
       });
 
       it("[[Call]] throws when this is not a conceptual tag", () => {
+        const involved = new Tag();
+        involved.persist();
         assertThrows(() => {
-          new Tag().addInvolvesTag();
+          new Tag().addInvolvesTag(involved);
         });
       });
 
diff --git a/schema.js b/schema.js
new file mode 100644 (file)
index 0000000..c708cfe
--- /dev/null
+++ b/schema.js
@@ -0,0 +1,114 @@
+// 📧🏷️ Étiquette ∷ schema.js
+// ====================================================================
+//
+// Copyright © 2023 Lady [@ Lady’s Computer].
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at <https://mozilla.org/MPL/2.0/>.
+//
+// ___
+//
+// ※ The definitions in this file are minimal and only really geared
+// towards supporting the functionality that 📧🏷️ Étiquette needs.
+
+/**
+ * Supported class types.
+ *
+ * The class `"Thing"` is not defined, but is used as a generic
+ * superclass.
+ */
+const classes = {
+  Tag: { subClassOf: "Thing" },
+  CanonTag: { subClassOf: "Tag" },
+  ConceptualTag: { subClassOf: "Tag" },
+  RelationshipTag: {
+    subClassOf: ["ConceptualTag", {
+      onProperty: "involves",
+      allValuesFrom: {
+        unionOf: ["CharacterTag", "RelationshipTag"],
+      },
+    }],
+  },
+  FriendshipTag: { subClassOf: "RelationshipTag" },
+  RivalryTag: { subClassOf: "RelationshipTag" },
+  FamilialRelationshipTag: { subClassOf: "RelationshipTag" },
+  RomanticRelationshipTag: { subClassOf: "RelationshipTag" },
+  SexualRelationshipTag: { subClassOf: "RelationshipTag" },
+  EntityTag: { subClassOf: "Tag" },
+  CharacterTag: { subClassOf: "EntityTag" },
+  InanimateEntityTag: { subClassOf: "EntityTag" },
+  GenreTag: { subClassOf: "Tag" },
+  SettingTag: { subClassOf: "Tag" },
+  LocationTag: { subClassOf: "SettingTag" },
+  TimePeriodTag: { subClassOf: "SettingTag" },
+  UniverseTag: { subClassOf: "SettingTag" },
+};
+
+/** Supported transitive object properties. */
+const transitiveProperties = {
+  broaderTransitive: { domain: "Tag", range: "Tag" },
+  narrowerTransitive: { inverseOf: "broaderTransitive" },
+};
+
+/** Supported object properties. */
+const objectProperties = {
+  broader: { subPropertyOf: "broaderTransitive" },
+  narrower: {
+    inverseOf: "broader",
+    subPropertyOf: "narrowerTransitive",
+  },
+  inCanon: {
+    domain: { unionOf: ["EntityTag", "SettingTag"] },
+    range: "CanonTag",
+  },
+  hasInCanon: { inverseOf: "inCanon" },
+  involves: { domain: "ConceptualTag", range: "Tag" },
+  involvedIn: { inverseOf: "involves" },
+  ...transitiveProperties,
+};
+
+/** Supported data properties. */
+const dataProperties = {
+  prefLabel: { domain: "Thing", range: "PlainLiteral" },
+  altLabel: { domain: "Thing", range: "PlainLiteral" },
+  hiddenLabel: { domain: "Thing", range: "PlainLiteral" },
+};
+
+/**
+ * Returns an immutable, null‐prototype object deeply derived from the
+ * provided one.
+ *
+ * ※ Once records and tuples are added to Ecmascript, the schema
+ * should be defined in terms of those primitives. In the meantime,
+ * this function at least ensures the schema is Very Immutable.
+ */
+const makeRecord = ($) => {
+  return Object.preventExtensions(
+    Object.create(
+      null,
+      Object.fromEntries([...function* () {
+        for (const [key, value] of Object.entries($)) {
+          if (Object(value) === value) {
+            const recordValue = makeRecord(value);
+            yield [key, { enumerable: true, value: recordValue }];
+          } else {
+            yield [key, { enumerable: true, value }];
+          }
+        }
+        if (Array.isArray($)) {
+          yield ["length", { value: $.length }];
+        } else {
+          /* do nothing */
+        }
+      }()]),
+    ),
+  );
+};
+
+export default makeRecord({
+  classes,
+  objectProperties,
+  transitiveProperties,
+  dataProperties,
+});
This page took 0.090667 seconds and 4 git commands to generate.