Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/cli/view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ For the per-count detail behind the verdict, open `#/health`. To narrow the conc

**Page metadata.** Each page shows its frontmatter fields - kind, contributing sources, confidence score, provenance state, creation and update timestamps - in the metadata rail on the right of the page.

**A typed entity page shows the fields its own type declares**, above that list and in the order the profile declares them. Values render by their declared type: an array becomes a list, an `enum` becomes a state chip, a boolean reads as Yes or No, and a field carrying a [`format`](/configuration/profiles#field-formats) becomes an external link. A field the record does not carry is left out rather than shown empty, and an `artifactRef` is named but marked unverified - the viewer does not yet read the artifact store to check it.

Those fields lead because the list below them is the default profile's vocabulary and describes a contract a typed page was never under. Anything that list would have shown still shows, minus any key the profile declares as a field of its own - so nothing disappears and nothing is stated twice.

**Freshness badges.** Pages whose underlying sources have changed since the last compile are labelled `STALE`. Pages whose sources were all deleted are labelled `ORPHANED`. Pages that declare contradictions in their frontmatter are labelled `CONTRADICTED`. Archived candidates show an `ARCHIVED` badge.

**Provenance and citation chips.** Each paragraph's `^[source.md]` citation renders as a clickable chip. On loopback (`127.0.0.1`), citation chips include an editor link that opens the source file at the relevant line range directly in your default editor. Specific claim citations (`^[source.md:42-58]`) pin to the exact line range.
Expand Down
28 changes: 28 additions & 0 deletions docs/configuration/profiles.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,34 @@ Supported field types:
Fields can be required with `required: true`, or by listing them in the entity's
`requiredFields` array. Both forms are enforced on typed page writes.

### Field formats

`format` tells a read surface how to linkify a field's text. It is valid only on
`string` and `string[]`, and takes one of three values:

| Format | Renders as |
|---|---|
| `url` | The value itself, linked — only when it parses as an absolute `http`/`https` URL |
| `doi` | A link through `https://doi.org/<value>` |
| `arxiv` | A link through `https://arxiv.org/abs/<value>` |

```json
{ "doi": { "type": "string", "format": "doi" } }
```

The vocabulary is closed and the resolver origins are fixed — a profile names a
resolver the reader already knows rather than supplying a URL template, so a
profile can never point a reader at an origin of its choosing. An unknown format
is rejected at load, and a value that does not match its format's grammar renders
as plain text rather than as a link built anyway.

<Note>
Adding `format` to an existing profile changes that profile's digest, and its
template's digest if it is a builtin. It does **not** affect in-flight workflow
runs: run classification compares the digest of the individual workflow
definition, not the profile's.
</Note>

### Page titles

`titleField` names the frontmatter key an entity type carries its display title
Expand Down
1 change: 1 addition & 0 deletions src/profile/schema/profile.v1.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"type": { "$ref": "#/$defs/fieldType" },
"required": { "type": "boolean" },
"default": {},
"format": { "enum": ["url", "doi", "arxiv"] },
"artifactTypes": { "type": "array", "items": { "type": "string" } },
"enum": { "type": "array", "items": { "type": "string" } },
"min": { "type": "number" },
Expand Down
64 changes: 40 additions & 24 deletions src/profile/templates/builtin/autosci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import { autosciArtifacts } from "./autosci/artifacts.js";
import { autosciEntities } from "./autosci/entities.js";
import { autosciRelations } from "./autosci/relations.js";
import { autosciWorkflowActions, autosciWorkflows } from "./autosci/workflows.js";
import { withoutTitleFields } from "../title-fields.js";
import { withoutFieldFormats, withoutTitleFields } from "../prior-releases.js";

const profile: ProfilePack = {
schemaVersion: 1,
profileId: "autosci",
profileVersion: "0.2.0",
profileVersion: "0.3.0",
displayName: "AutoSci",
entities: autosciEntities,
relations: autosciRelations,
Expand All @@ -29,40 +29,56 @@ const profile: ProfilePack = {
workflowActions: autosciWorkflowActions,
};

const DESCRIPTION =
"AutoSci-style research profile with papers, ideas, experiments, manuscripts, artifacts, workflows, and Crossref import.";

/** One superseded AutoSci release, built from the current pack's shared envelope. */
function priorRelease(version: string, entities: ProfilePack["entities"]): ProfileTemplatePackage {
return {
schemaVersion: 1,
templateId: "autosci",
version,
displayName: "AutoSci",
publisher: "atomicstrata",
sourceType: "builtin",
license: "MIT",
minLlmwikiVersion: "1.0.0",
description: DESCRIPTION,
profile: { ...profile, profileVersion: version, entities },
};
}

/**
* The superseded `0.1.0` release, retained so secure update planning can resolve
* the EXACT installed release rather than reinterpreting it as the newest
* package sharing its template id. Without it, `planBuiltinTemplateUpdate`
* throws for every project still on `0.1.0`. Its entity block is `0.2.0` minus
* the title declarations — see {@link withoutTitleFields}.
* Every superseded release, retained so secure update planning can resolve the
* EXACT installed release rather than reinterpreting it as the newest package
* sharing its template id. Without them, `planBuiltinTemplateUpdate` throws for
* every project still on one.
*
* Each entity block is DERIVED from the current one by undoing exactly the
* change that release did not have, and the derivations compose backwards:
* `0.2.0` is `0.3.0` without the field formats, and `0.1.0` is that without the
* title declarations. Each published digest is pinned in
* `test/profile-template-releases.test.ts`, so a later edit that corrupts
* a derivation fails there rather than silently mis-describing an installed
* project. See {@link withoutFieldFormats}.
*/
const AUTOSCI_TEMPLATE_0_1_0: ProfileTemplatePackage = {
schemaVersion: 1,
templateId: "autosci",
version: "0.1.0",
displayName: "AutoSci",
publisher: "atomicstrata",
sourceType: "builtin",
license: "MIT",
minLlmwikiVersion: "1.0.0",
description:
"AutoSci-style research profile with papers, ideas, experiments, manuscripts, artifacts, workflows, and Crossref import.",
profile: { ...profile, profileVersion: "0.1.0", entities: withoutTitleFields(autosciEntities) },
};
const AUTOSCI_0_2_0_ENTITIES = withoutFieldFormats(autosciEntities);

/** Every published AutoSci release, newest last. */
export const AUTOSCI_TEMPLATE_RELEASES: readonly ProfileTemplatePackage[] = [AUTOSCI_TEMPLATE_0_1_0];
export const AUTOSCI_TEMPLATE_RELEASES: readonly ProfileTemplatePackage[] = [
priorRelease("0.1.0", withoutTitleFields(AUTOSCI_0_2_0_ENTITIES)),
priorRelease("0.2.0", AUTOSCI_0_2_0_ENTITIES),
];

/** Builtin install package for AutoSci-derived research projects. */
export const AUTOSCI_TEMPLATE: ProfileTemplatePackage = {
schemaVersion: 1,
templateId: "autosci",
version: "0.2.0",
version: "0.3.0",
displayName: "AutoSci",
publisher: "atomicstrata",
sourceType: "builtin",
license: "MIT",
minLlmwikiVersion: "1.0.0",
description: "AutoSci-style research profile with papers, ideas, experiments, manuscripts, artifacts, workflows, and Crossref import.",
description: DESCRIPTION,
profile,
};
6 changes: 3 additions & 3 deletions src/profile/templates/builtin/autosci/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ export const autosciEntities = {
authors: { type: "string[]", required: true },
year: { type: "integer" },
venue: { type: "string" },
doi: { type: "string" },
arxivId: { type: "string" },
doi: { type: "string", format: "doi" },
arxivId: { type: "string", format: "arxiv" },
triageNote: { type: "string" },
distilledSummary: { type: "string" },
stage: { type: "enum", enum: ["imported", "triaged", "distilled"], required: true },
Expand All @@ -37,7 +37,7 @@ export const autosciEntities = {
fields: {
title: { type: "string" },
kind: { type: "enum", enum: ["paper", "repo", "video", "web"] },
locator: { type: "string" },
locator: { type: "string", format: "url" },
stage: { type: "enum", enum: ["imported", "triaged"] },
},
lifecycle: {
Expand Down
2 changes: 1 addition & 1 deletion src/profile/templates/builtin/newsroom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/
import type { ProfilePack } from "../../types.js";
import type { ProfileTemplatePackage } from "../types.js";
import { withoutTitleFields } from "../title-fields.js";
import { withoutTitleFields } from "../prior-releases.js";

export const newsroomEntities = {
articles: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* @file src/profile/templates/title-fields.ts
* @description Deriving a builtin template's PRE-`titleField` release.
* @file src/profile/templates/prior-releases.ts
* @description Deriving a builtin template's PRIOR releases from its current one.
*
* Lives OUTSIDE `builtin/` on purpose. That directory is the template-DATA
* allowlist `test/profile-template-genericity.test.ts` enumerates — the only
Expand Down Expand Up @@ -44,3 +44,36 @@ export function withoutTitleFields(entities: ProfilePack["entities"]): ProfilePa
}),
);
}

/**
* The same entity block with every field's `format` declaration removed.
*
* The second such derivation, and it composes with the first: a release two
* versions back is `withoutTitleFields(withoutFieldFormats(current))`. Each is a
* single named change, so the composition reads as the changelog it is.
*
* @param entities - The CURRENT release's entity block.
* @returns The block as the pre-`format` release published it.
*/
export function withoutFieldFormats(entities: ProfilePack["entities"]): ProfilePack["entities"] {
return Object.fromEntries(
Object.entries(entities).map(([type, def]) => {
// Spread-then-overwrite would give a field-less type an explicit
// `fields: undefined`, i.e. a KEY the published release did not have.
// Canonicalization happens to drop it, but a retained release should be
// shaped like what it published rather than rely on that.
if (def.fields === undefined) return [type, def];
return [type, { ...def, fields: strippedFields(def.fields) }];
}),
);
}

/** A field map with each field's `format` declaration removed. */
function strippedFields(fields: Record<string, { format?: unknown }>): Record<string, unknown> {
return Object.fromEntries(
Object.entries(fields).map(([name, field]) => {
const { format: _dropped, ...rest } = field;
return [name, rest];
}),
);
}
19 changes: 19 additions & 0 deletions src/profile/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,28 @@ export type FieldType =
| "artifactRef"
| "artifactRef[]";

/**
* Declarative PRESENTATION hints for a text field: enough for a read surface to
* build an external link generically, and nothing more.
*
* A CLOSED vocabulary rather than a URL template. A template would be an
* author-supplied string a renderer interpolates into an href — executable
* profile behaviour reaching a read surface — whereas these three name a
* resolver the READER already knows, so the origin stays out of profile control.
* An unknown value is rejected at load, so no renderer has to guess.
*/
export type FieldFormat = "url" | "doi" | "arxiv";

/** Declarative definition of a single frontmatter field on an entity type. */
export interface FieldDef {
type: FieldType;
/**
* How a read surface may linkify this field's text. Valid only on `string` and
* `string[]` (see `validateTitleField`'s neighbour in validate.ts): a format is
* a hint about how to read text, and on any other type it would be config no
* renderer could act on.
*/
format?: FieldFormat;
required?: boolean;
default?: unknown;
enum?: string[];
Expand Down
35 changes: 35 additions & 0 deletions src/profile/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,38 @@ function validateRequiredFields(entityType: string, def: EntityTypeDef): void {
}
}

/**
* Field types a declarative `format` can describe. A format is a hint about how
* to read TEXT, so on a boolean, number, date, enum or artifactRef it would be
* config no renderer could act on — rejected at load rather than ignored later.
*/
const FORMATTABLE_TYPES: ReadonlySet<FieldType> = new Set<FieldType>(["string", "string[]"]);

/**
* A declared `format` must sit on a field whose type it can describe.
*
* Applied to EVERY `FieldDef` carrier, not just entity fields: the schema
* resolves relation `attributes` and artifact `metadata` to the same
* `$defs/fieldDef`, so all three would otherwise accept a format the contract
* says is invalid — and artifact metadata is projected onto the wire. Same
* placement discipline as {@link assertArtifactTypesScoped}, which is wired into
* all three for the same reason.
*/
function assertFieldFormatScoped(where: string, name: string, field: FieldDef): void {
if (field.format === undefined) return;
assert(
FORMATTABLE_TYPES.has(field.type),
`${where} field '${name}': format '${field.format}' is not valid on type '${field.type}'`,
);
}

/** Every declared `format` on an entity's fields must describe a text type. */
function validateFieldFormats(entityType: string, def: EntityTypeDef): void {
for (const [name, field] of Object.entries(def.fields ?? {})) {
assertFieldFormatScoped(`entity '${entityType}'`, name, field);
}
}

/**
* `titleField` must name a DECLARED field that can actually hold a title.
*
Expand Down Expand Up @@ -365,6 +397,7 @@ function validateEntity(entityType: string, def: EntityTypeDef, declaredArtifact
validateArtifactTypesScope(entityType, def, declaredArtifactTypes);
validateRequiredFields(entityType, def);
validateTitleField(entityType, def);
validateFieldFormats(entityType, def);
validateContentTiers(entityType, def);
const warnings = def.lifecycle ? validateLifecycle(entityType, def.lifecycle, def.fields) : [];
return { canonicalDirectory, warnings };
Expand Down Expand Up @@ -405,6 +438,7 @@ function validateRelationAttributes(rel: string, def: RelationTypeDef, declaredA
for (const [name, field] of Object.entries(attributes)) {
assertFieldDefFinite(`relation '${rel}' attribute '${name}'`, field);
assertArtifactTypesScoped(`relation '${rel}'`, name, field, declaredArtifactTypes);
assertFieldFormatScoped(`relation '${rel}'`, name, field);
}
for (const name of def.requiredAttributes ?? []) {
assert(name in attributes, `relation '${rel}' requiredAttributes references undeclared attribute '${name}'`);
Expand Down Expand Up @@ -590,6 +624,7 @@ function validateArtifacts(profile: ProfilePack, declaredArtifactTypes: Set<stri
assert(fd.type !== "artifactRef" && fd.type !== "artifactRef[]",
`artifact ${JSON.stringify(id)} metadata field ${JSON.stringify(field)} is artifactRef-typed — nested artifact refs are not supported in v0`);
assertArtifactTypesScoped(`artifact ${JSON.stringify(id)} metadata`, field, fd, declaredArtifactTypes);
assertFieldFormatScoped(`artifact ${JSON.stringify(id)} metadata`, field, fd);
}
}
}
Expand Down
45 changes: 45 additions & 0 deletions src/viewer/assets/viewer-content.css
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,51 @@

/* ---- Support rail: warnings block ---- */

/*
* A typed page's DECLARED fields. The `<dl>` itself needs no rule — the rail's
* own `.support-rail dt/dd` already style it — so only the pieces the fixed list
* has no equivalent for are defined here.
*
* The label is MONO because it is a frontmatter key, not prose: it is the exact
* string the author typed and the reader may have to type back. Same reasoning
* as the run id on a workflow row.
*/
/* Scoped to `.support-rail` to out-specify `.support-rail dt` in
* viewer-chrome.css (0-1-1) — a bare `.entity-field-label` (0-1-0) loses to it
* and the label would silently render in the sans face this comment denies. */
.support-rail .entity-field-label { font: var(--type-metadata); color: var(--fg-dim); }
.entity-field-list { list-style: none; margin: 0; padding: 0; }
.entity-field-item { color: var(--fg-body); }
/* A formatted field leaves the viewer, so it looks like a link rather than
* inheriting the rail's plain body colour. `--accent-text`, not `--accent`:
* the latter is the graphics fill (graph nodes, gradients), and text uses the
* readable-on-background variant. */
.entity-field-link { color: var(--accent-text); text-decoration: underline; }

/* An enum value is a state, not free text — so it takes the neutral chip recipe
* this stylesheet already uses for `.workflow-flag`, rather than #/pipeline's,
* which is tuned for a denser panel and sits in its own sheet. */
.entity-field-state {
display: inline-block;
font: var(--type-badge);
color: var(--fg-dim);
background: var(--bg-chip);
border: 1px solid var(--border-chip);
padding: 3px 6px;
border-radius: var(--radius-badge);
}

/* An artifact ref is a machine handle; the note beside it says it is unchecked.
* `--fg-dim` rather than a fainter rung: the whole point of the note is that it
* gets read, and the fainter tokens do not clear 4.5:1 at this size. */
.entity-field-ref { font: var(--type-provenance); color: var(--fg-body); }
.entity-field-unresolved {
display: block;
font: var(--type-metadata);
color: var(--fg-dim);
font-style: italic;
}

.support-rail-warnings {
margin-top: var(--space-16);
padding-top: var(--space-12);
Expand Down
Loading
Loading