Skip to content
Merged
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
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 24 additions & 1 deletion packages/classifications/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,33 @@ const atomData = buildAtomData('ethereum-account', {
})
```

Each classification separates recommended atom fields from recommended triple
metadata:

```ts
const book = getClassification('book')

book?.schema
// { context: 'https://schema.org/', type: 'Book' }

book?.fields.map((field) => field.schemaProperty)
// ['name', 'author', 'isbn', 'sameAs']

book?.metadataPredicates
// ['authoredBy', 'publisher', 'hasCategory', ...]
```

`schema` is a generic schema pointer. Today most public classifications point at
schema.org, but the field is intentionally not named `schemaOrg` so other schema
sources can be supported later. For schema.org-backed classifications,
`schemaProperty` points at the canonical schema.org property name. It does not
copy inheritance or provenance into the classification; consumers can derive that
from `@0xintuition/schema-org`.

Known classifications are also available as direct subpath imports:

```ts
import { ethereumAccount } from '@0xintuition/classifications/ethereum-account'
```

Ethereum classifications use the immutable `https://schema.intuition.systems/v1/ethereum.jsonld` JSON-LD context and are gated on `schema.intuition.systems` resolving before publication.
Ethereum and other Intuition-local classifications use immutable `https://schema.intuition.systems/v1/...` JSON-LD contexts and are gated on `schema.intuition.systems` resolving before publication.
2 changes: 2 additions & 0 deletions packages/classifications/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
"check:unsafe:write": "biome check --write --unsafe"
},
"devDependencies": {
"@0xintuition/predicates": "workspace:*",
"@0xintuition/schema-org": "workspace:*",
"typescript": "catalog:",
"vitest": "catalog:"
}
Expand Down
4 changes: 2 additions & 2 deletions packages/classifications/src/atom-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ export function buildAtomDataObject(

const dataEntries: Array<[string, unknown]> = [];

if (spec.schemaOrg) {
dataEntries.push(['@context', spec.schemaOrg.context], ['@type', spec.type]);
if (spec.schema) {
dataEntries.push(['@context', spec.schema.context], ['@type', spec.type]);
}

for (const field of spec.fields) {
Expand Down
123 changes: 123 additions & 0 deletions packages/classifications/src/classification-references.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { PREDICATE_DEFS } from '@0xintuition/predicates';
import { getPropertiesFor, getType } from '@0xintuition/schema-org';
import { describe, expect, it } from 'vitest';

import { CLASSIFICATION_SPECS, getClassification, getMetadataPredicatesFor } from './index.js';

const SCHEMA_ORG_CONTEXT = 'https://schema.org/';

describe('classification references', () => {
it('references only predicate keys exported by @0xintuition/predicates', () => {
const predicateKeys = new Set(Object.keys(PREDICATE_DEFS));
const issues: string[] = [];

for (const spec of CLASSIFICATION_SPECS) {
const seen = new Set<string>();

for (const predicateKey of spec.metadataPredicates) {
if (seen.has(predicateKey)) {
issues.push(`${spec.slug}: duplicate metadata predicate "${predicateKey}"`);
}

seen.add(predicateKey);

if (!predicateKeys.has(predicateKey)) {
issues.push(`${spec.slug}: unknown metadata predicate "${predicateKey}"`);
}
}
}

expect(issues).toEqual([]);
});

it('resolves schema.org-backed fields through inherited schema.org properties', () => {
const issues: string[] = [];

for (const spec of CLASSIFICATION_SPECS) {
if (spec.schema?.context !== SCHEMA_ORG_CONTEXT) {
continue;
}

const schemaType = getType(spec.schema.type);

if (!schemaType) {
issues.push(`${spec.slug}: unknown schema.org type "${spec.schema.type}"`);
continue;
}

const schemaPropertyNames = new Set(
getPropertiesFor(schemaType.name).map((property) => property.name)
);

for (const field of spec.fields) {
if (!field.schemaProperty) {
issues.push(`${spec.slug}.${field.key}: missing schemaProperty pointer`);
continue;
}

if (!schemaPropertyNames.has(field.schemaProperty)) {
issues.push(
`${spec.slug}.${field.key}: unknown schema.org property "${field.schemaProperty}" for ${schemaType.name}`
);
}
}
}

expect(issues).toEqual([]);
});

it('keeps schema.org provenance intact for Book recommended fields', () => {
const book = getClassification('book');
expect(book?.metadataPredicates).toContain('authoredBy');
expect(book?.metadataPredicates).toContain('sameAs');

const properties = getPropertiesFor('Book');
const name = properties.find((property) => property.name === 'name');
const author = properties.find((property) => property.name === 'author');
const isbn = properties.find((property) => property.name === 'isbn');

expect(name?.originType).toBe('Thing');
expect(author?.originType).toBe('CreativeWork');
expect(isbn?.originType).toBe('Book');
});

it('wires MusicRecording metadata predicates only after predicate promotion', () => {
expect(getMetadataPredicatesFor('music-recording')).toEqual([
'byArtist',
'inAlbum',
'inPlaylist',
'hasCategory',
'sameAs',
]);
});

it('recommends sameAs for schema.org-backed entities while preserving explicit exclusions', () => {
expect(getClassification('music-recording')?.fields.map((field) => field.key)).toContain(
'sameAs'
);
expect(getClassification('music-album')?.metadataPredicates).toContain('sameAs');
expect(getClassification('person')?.fields.map((field) => field.key)).toContain('sameAs');

for (const slug of [
'aggregate-rating',
'ethereum-account',
'ethereum-erc20',
'ethereum-smart-contract',
'social-media-account',
'social-media-posting',
]) {
expect(getClassification(slug)?.metadataPredicates).not.toContain('sameAs');
}
});

it('keeps social media account on an Intuition-owned schema context', () => {
const socialMediaAccount = getClassification('social-media-account');

expect(socialMediaAccount?.schema).toEqual({
context: 'https://schema.intuition.systems/v1/social-media-account.jsonld',
type: 'SocialMediaAccount',
});
expect(socialMediaAccount?.fields.every((field) => !field.schemaProperty)).toBe(true);
expect(socialMediaAccount?.metadataPredicates).toContain('linkedAccount');
});
});
13 changes: 12 additions & 1 deletion packages/classifications/src/classifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ describe('classification atom data', () => {
it('builds Intuition schema-backed atom data for blockchain classifications', () => {
// Pin the canonical serialized atom data for every Ethereum classification —
// these strings determine the on-chain atom IDs forever once published. Any
// silent change to schemaOrg, field order, or whitespace would flip them.
// silent change to schema, field order, or whitespace would flip them.
expect(
buildAtomData('ethereum-account', {
address: '0x0000000000000000000000000000000000000001',
Expand Down Expand Up @@ -99,6 +99,17 @@ describe('classification atom data', () => {
);
});

it('builds Intuition schema-backed atom data for social media accounts', () => {
expect(
buildAtomData('social-media-account', {
username: 'karpathy',
platform: 'x',
})
).toBe(
'{"@context":"https://schema.intuition.systems/v1/social-media-account.jsonld","@type":"SocialMediaAccount","username":"karpathy","platform":"x"}'
);
});

it('reports validation issues without throwing', () => {
expect(
validateClassificationValues('web-page', {
Expand Down
9 changes: 8 additions & 1 deletion packages/classifications/src/classifications.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { CLASSIFICATION_SPECS as GENERATED_CLASSIFICATION_SPECS } from './generated/index.js';
import type { ClassificationCategory, ClassificationSpec } from './types.js';
import type { ClassificationCategory, ClassificationSpec, PredicateKeyReference } from './types.js';

const CLASSIFICATION_SPECS_FROZEN = deepFreeze(
GENERATED_CLASSIFICATION_SPECS.map((spec) => ({
...spec,
fields: [...spec.fields],
metadataPredicates: [...spec.metadataPredicates],
}))
) as readonly ClassificationSpec[];

Expand All @@ -31,6 +32,12 @@ export function getClassificationsByCategory(
return CLASSIFICATION_SPECS_FROZEN.filter((spec) => spec.category === category);
}

export function getMetadataPredicatesFor(
slug: string
): readonly PredicateKeyReference[] | undefined {
return CLASSIFICATION_MAP.get(slug)?.metadataPredicates;
}

function deepFreeze<T>(value: T): T {
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
Object.freeze(value);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ export const aggregateRating: ClassificationSpec = {
displayName: 'Aggregate Rating',
description: 'A stable aggregate rating summary for a reviewed thing.',
category: 'Product',
schemaOrg: { context: 'https://schema.org/', type: 'AggregateRating' },
schema: { context: 'https://schema.org/', type: 'AggregateRating' },
metadataPredicates: ['itemReviewed', 'authoredBy'] as const,
fields: [
{
key: 'ratingValue',
schemaProperty: 'ratingValue',
label: 'Rating Value',
description: 'The aggregate average rating.',
fieldType: 'number',
Expand All @@ -18,6 +20,7 @@ export const aggregateRating: ClassificationSpec = {
},
{
key: 'reviewCount',
schemaProperty: 'reviewCount',
label: 'Review Count',
description: 'The number of reviews represented by the aggregate.',
fieldType: 'integer',
Expand All @@ -26,6 +29,7 @@ export const aggregateRating: ClassificationSpec = {
},
{
key: 'bestRating',
schemaProperty: 'bestRating',
label: 'Best Rating',
description: 'The upper bound of the rating scale when needed.',
fieldType: 'number',
Expand All @@ -34,6 +38,7 @@ export const aggregateRating: ClassificationSpec = {
},
{
key: 'worstRating',
schemaProperty: 'worstRating',
label: 'Worst Rating',
description: 'The lower bound of the rating scale when needed.',
fieldType: 'number',
Expand Down
24 changes: 23 additions & 1 deletion packages/classifications/src/generated/specs/article.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,21 @@ export const article: ClassificationSpec = {
displayName: 'Article',
description: 'A written article with a durable headline and canonical URL.',
category: 'Creative Work',
schemaOrg: { context: 'https://schema.org/', type: 'Article' },
schema: { context: 'https://schema.org/', type: 'Article' },
metadataPredicates: [
'authoredBy',
'publisher',
'hasDescription',
'url',
'reference',
'listedIn',
'hasCategory',
'sameAs',
] as const,
fields: [
{
key: 'headline',
schemaProperty: 'headline',
label: 'Headline',
description: 'The title or headline of the article.',
fieldType: 'string',
Expand All @@ -18,6 +29,7 @@ export const article: ClassificationSpec = {
},
{
key: 'description',
schemaProperty: 'description',
label: 'Description',
description: 'A short summary of the article.',
fieldType: 'string',
Expand All @@ -26,12 +38,22 @@ export const article: ClassificationSpec = {
},
{
key: 'url',
schemaProperty: 'url',
label: 'Article URL',
description: 'The canonical article URL.',
fieldType: 'url',
required: false,
placeholder: 'https://example.com/article',
},
{
key: 'sameAs',
schemaProperty: 'sameAs',
label: 'Canonical References',
description: 'Canonical URLs that identify the same article.',
fieldType: 'string[]',
required: false,
placeholder: 'https://example.com/...',
},
],
defaults: { pluginId: 'article', provider: 'dictionary' },
};
14 changes: 13 additions & 1 deletion packages/classifications/src/generated/specs/book.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,19 @@ export const book: ClassificationSpec = {
displayName: 'Book',
description: 'A book identity with only the title and optional disambiguators.',
category: 'Creative Work',
schemaOrg: { context: 'https://schema.org/', type: 'Book' },
schema: { context: 'https://schema.org/', type: 'Book' },
metadataPredicates: [
'authoredBy',
'publisher',
'hasCategory',
'reference',
'listedIn',
'sameAs',
] as const,
fields: [
{
key: 'name',
schemaProperty: 'name',
label: 'Title',
description: 'The title of the book.',
fieldType: 'string',
Expand All @@ -18,6 +27,7 @@ export const book: ClassificationSpec = {
},
{
key: 'author',
schemaProperty: 'author',
label: 'Author',
description: 'The author name when disambiguation is needed.',
fieldType: 'string',
Expand All @@ -26,6 +36,7 @@ export const book: ClassificationSpec = {
},
{
key: 'isbn',
schemaProperty: 'isbn',
label: 'ISBN',
description: 'The ISBN identifier when known.',
fieldType: 'string',
Expand All @@ -34,6 +45,7 @@ export const book: ClassificationSpec = {
},
{
key: 'sameAs',
schemaProperty: 'sameAs',
label: 'Canonical References',
description: 'Canonical URLs that identify the same book.',
fieldType: 'string[]',
Expand Down
Loading
Loading