diff --git a/README.md b/README.md
index a37980e..c9d4130 100644
--- a/README.md
+++ b/README.md
@@ -8,9 +8,9 @@ This fork monitors both `app.certified.actor.profile` and `app.certified.actor.o
| Label | Score | Meaning |
|-------|-------|---------|
-| ⚠ Likely Test | 0-39 | Placeholder, junk, or obvious test data |
-| ● Standard | 40-69 | A valid organization record with the basics filled in |
-| ✦ High Quality | 70-100 | A complete organization record with several strong details |
+| ⚠ Likely Test | signal-based | Explicit test evidence, such as a configured test PDS or obvious placeholder identity data |
+| ● Standard | 0-69 without test signals | A non-test organization record that does not meet the high-quality threshold |
+| ✦ High Quality | 70+ without test signals | A complete organization record with several strong details |
## Quick Start
@@ -93,7 +93,7 @@ Scores `app.certified.actor.organization` records on 13 completeness signals (10
Trusted PDS scoring uses the actor's resolved PDS host, not the profile website or organization URLs. Actor PDS lookup runs through the durable recompute queue; the first record from an uncached actor may be labeled by content score first, then corrected once the actor DID document is resolved.
-Test detection: authenticity checks catch common placeholder strings (`test`, `asdf`, `lorem ipsum`, etc.) and override the score to force ⚠ Likely Test. Operators can also set `TEST_PDS_HOSTS` to force actors from known development PDS hosts into ⚠ Likely Test.
+Test detection is intentionally conservative: hard `testSignals` such as configured `TEST_PDS_HOSTS`, obvious placeholder display names, placeholder domains, or `lorem ipsum` descriptions force ⚠ Likely Test. Softer data-quality issues become `validationNotes` for the dashboard but do not change the tier. Generic words like `test`, `testing`, or `tested` are allowed in profile descriptions, but still show as validation notes in short metadata fields such as organization type or URL labels.
### URL enrichment
diff --git a/src/app/docs/page.tsx b/src/app/docs/page.tsx
index 8c63cb2..3fbff8b 100644
--- a/src/app/docs/page.tsx
+++ b/src/app/docs/page.tsx
@@ -17,7 +17,7 @@ const SCORING_CRITERIA = [
{
label: 'Description',
points: COMPLETENESS_WEIGHTS.description,
- description: 'Awards points when the profile description is present and not obvious test data.',
+ description: 'Awards points when the profile description is present; generic words like test, testing, or tested are allowed in descriptions.',
},
{
label: 'Organization type',
@@ -166,9 +166,9 @@ export default function DocsPage() {
3.
- The scoring engine checks an authenticity gate first. Records with authenticity failures are labeled
- before completeness scoring begins. Validation notes are shown
- separately and do not affect tiering. Passing records then receive the 100-point completeness score.
+ The scoring engine separates hard test signals from softer validation notes. Records with test signals
+ are labeled . Records without test signals receive the 100-point
+ completeness score and are labeled standard or high-quality from that score.
@@ -186,11 +186,11 @@ export default function DocsPage() {
Scoring criteria
- Each passing organization record is evaluated on 13 completeness criteria for a maximum of 100 completeness
- points, plus a configurable actor-PDS trust bonus. The rubric now gives less credit to basic identity fields
+ Each organization record is evaluated on 13 completeness criteria for a maximum of 100 completeness
+ points, plus a configurable actor-PDS trust bonus. The rubric gives less credit to basic identity fields
and more weight to harder-to-fake credibility signals like website resolution, location, and visual/profile
- completeness. The authenticity gate runs first, validation notes are informational only, and labels stay
- attached to the organization record URI.
+ completeness. Hard test signals override the score to likely-test; validation notes are informational only,
+ and labels stay attached to the organization record URI.
@@ -232,27 +232,29 @@ export default function DocsPage() {
- Authenticity gate
+ Test signals and validation notes
- Matching any gate failure forces . These are authenticity failures, not
- validation notes, and there are no separate numeric deductions.
+ Matching a hard test signal forces . Validation notes are softer data-quality
+ issues shown to operators, but they do not change the tier.
-
Authenticity gate failure patterns
+
Hard test signal patterns
- - • Common junk values: test, asdf, lorem ipsum, placeholder, delete me, ignore, todo, foo, bar, abc, wip, sample, and example.
- - • Empty-style values: n/a, none, null, undefined, blank, draft, temp, and tmp.
- - • Display-name workflow/test terms such as demo, dev, staging, qa, e2e, sandbox, fixture, seed data, obvious glued test fixtures like tobytest, generic names like org/org 1/first org/new org, new db, published, unpublished, and changes requested.
- - • Placeholder domains such as example.com, example.org, example.net, and reserved .test/.example/.invalid hostnames fail URL authenticity checks.
- - • Single-word greetings, repeated characters, repeated-character runs in display names, and numeric-only values are also treated as test data.
+ - • Actors hosted on configured TEST_PDS_HOSTS.
+ - • Placeholder display names such as test org, demo, dev, staging, qa, e2e, sandbox, fixture, obvious glued test fixtures like tobytest, generic names like org/org 1/first org/new org, and workflow states like published, unpublished, or changes requested.
+ - • Obvious junk descriptions such as lorem ipsum or a description that is only placeholder text. Generic words like test, testing, or tested are allowed in descriptions.
+ - • Placeholder domains such as example.com, example.org, example.net, and reserved .test/.example/.invalid hostnames.
+ - • Repeated-character runs in display names.
Validation notes
- These are informational only. They can appear when fallback handling keeps usable profile fields and
- drops malformed optional fields, and they do not imply suspicious activity or affect tiering.
+ These are informational only. They can appear when fallback handling keeps usable profile fields,
+ optional fields are malformed, short metadata fields such as organization type or URL label contain
+ placeholder/test words, or the record has very little metadata. They do not imply suspicious activity
+ or affect tiering.
@@ -262,8 +264,8 @@ export default function DocsPage() {
Quality tiers
- Scores map to three tiers. Authenticity gate failures override the numeric score and always produce a
- label. Validation notes do not change the tier.
+ Scores map to standard or high-quality unless a hard test signal is present. Test signals override the
+ numeric score and produce a label. Validation notes do not change the tier.
{[
@@ -274,13 +276,13 @@ export default function DocsPage() {
},
{
tier: 'standard' as const,
- range: '40 – 69',
- detail: 'Decent merged record with some useful metadata, but not full rubric coverage.',
+ range: '0 – 69 without test signals',
+ detail: 'Non-test merged record with anything below the high-quality threshold.',
},
{
tier: 'likely-test' as const,
- range: '0 – 39 or gate failures',
- detail: 'Contains authenticity gate failures, or falls below the standard threshold.',
+ range: 'test signals only',
+ detail: 'Contains hard test evidence, such as a configured test PDS, placeholder display name, or placeholder domain.',
},
].map(({ tier, range, detail }) => (
diff --git a/src/components/ScoreBreakdown.tsx b/src/components/ScoreBreakdown.tsx
index 605a061..a492bd8 100644
--- a/src/components/ScoreBreakdown.tsx
+++ b/src/components/ScoreBreakdown.tsx
@@ -47,7 +47,7 @@ export function ScoreBreakdown({ breakdown, validationNotes = [], testSignals }:
return (
- Authenticity gate failures are checked before completeness scoring.
+ Hard test signals override tiering; validation notes are informational only.
{CRITERIA.map(({ label, field, max }) => {
@@ -137,7 +137,7 @@ export function ScoreBreakdown({ breakdown, validationNotes = [], testSignals }:
- Authenticity gate failed
+ Hard test signal
{testSignals.map((signal, i) => (
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 5a25243..f2e06f4 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -1,8 +1,7 @@
import type { LabelDefinition } from './types'
type RuntimeQualityTier = 'likely-test' | 'standard' | 'high-quality'
-
-export const AUTHENTICITY_FAILURE_TIER: RuntimeQualityTier = 'likely-test'
+type ScoreBasedQualityTier = Exclude
// Shared time unit for score boundaries.
export const MS_PER_DAY = 24 * 60 * 60 * 1000
@@ -39,11 +38,12 @@ export const COMPLETENESS_WEIGHTS = {
banner: 10,
} as const
-// Final score bands are intentionally coarse. High-quality is open-ended
-// because configured bonuses can raise the final score above 100 completeness points.
-export const SCORE_THRESHOLDS: Record = {
- 'likely-test': { min: 0, max: 39 },
- standard: { min: 40, max: 69 },
+// Final score bands are intentionally coarse. Likely-test is signal-based:
+// records without hard test evidence default to standard until they meet the
+// high-quality threshold. High-quality is open-ended because configured bonuses
+// can raise the final score above 100 completeness points.
+export const SCORE_THRESHOLDS: Record = {
+ standard: { min: 0, max: 69 },
'high-quality': { min: 70, max: Number.POSITIVE_INFINITY },
}
@@ -65,10 +65,16 @@ export const DEFAULT_TRUSTED_PDS_HOSTS = ['certified.one', 'gainforest.id'] as c
/** Default score points added when an actor is hosted on a trusted PDS. */
export const DEFAULT_TRUSTED_PDS_BONUS = 10
-export const TEST_PATTERNS: RegExp[] = [
- // Word-boundary "test" — catches "Another Test", "Test Contributors", "This is testing", "test 123"
+/**
+ * Test-word patterns are hard evidence in short identity fields like display
+ * names, but too common for free-form descriptions.
+ */
+export const TEST_WORD_PATTERNS: RegExp[] = [
/\btest(ing|ed|er|s)?\b/i,
+]
+/** Obvious placeholder strings that can be checked outside descriptions. */
+export const TEST_PATTERNS: RegExp[] = [
// Common junk prefixes and phrases.
/^asdf/i, /\blorem ipsum\b/i, /^placeholder/i, /^delete me/i, /^ignore/i, /^zzz/i,
@@ -102,11 +108,37 @@ export const AUTHENTICITY_TEXT_PATTERNS: RegExp[] = [
/^unlisted$/i,
]
+/** Placeholder patterns for short metadata fields, where test words are meaningful signals. */
+export const SHORT_FIELD_AUTHENTICITY_TEXT_PATTERNS: RegExp[] = [
+ ...TEST_WORD_PATTERNS,
+ ...AUTHENTICITY_TEXT_PATTERNS,
+]
+
+/**
+ * Placeholder patterns for descriptions. Generic test words are intentionally
+ * excluded because real organization descriptions often mention tests, testing,
+ * or tested methods.
+ */
+export const DESCRIPTION_AUTHENTICITY_TEXT_PATTERNS: RegExp[] = [
+ /\blorem ipsum\b/i,
+ /^asdf/i,
+ /^placeholder$/i,
+ /^delete me$/i,
+ /^ignore$/i,
+ /^todo$/i,
+ /^n\/a$/i,
+ /^none$/i,
+ /^null$/i,
+ /^undefined$/i,
+ /^blank$/i,
+]
+
/**
* Extra authenticity patterns for display names, where workflow/test labels are
* much stronger evidence than the same words appearing in longer descriptions.
*/
export const DISPLAY_NAME_AUTHENTICITY_TEXT_PATTERNS: RegExp[] = [
+ ...TEST_WORD_PATTERNS,
...AUTHENTICITY_TEXT_PATTERNS,
/(?:^|[^\p{Letter}\p{Number}])(?:demo|dev|staging|qa|e2e|sandbox|fixture)(?:$|[^\p{Letter}\p{Number}])/iu,
/^tobytest\d*$/i,
@@ -122,7 +154,7 @@ export const DISPLAY_NAME_AUTHENTICITY_TEXT_PATTERNS: RegExp[] = [
/** Reserved example domains that should never count as organization evidence. */
export const PLACEHOLDER_DOMAINS = ['example.com', 'example.net', 'example.org'] as const
-/** Reserved non-production TLDs that should fail the authenticity gate. */
+/** Reserved non-production TLDs that should produce a hard test signal. */
export const PLACEHOLDER_TLDS = ['example', 'invalid', 'test'] as const
export const LABEL_LIMIT = 1
diff --git a/src/lib/hf-classifier.ts b/src/lib/hf-classifier.ts
index b16a7f8..ee3c6aa 100644
--- a/src/lib/hf-classifier.ts
+++ b/src/lib/hf-classifier.ts
@@ -2,6 +2,7 @@ import { HfInference } from '@huggingface/inference'
import * as config from './config'
import { HF_POSITIVE_LABEL, updateActivityHfFields, getActivityByDidRkey, getHfClassifiedNonFlagged } from './db'
import { updateActivity } from './db'
+import { tierForScore } from './scorer'
export interface ContentClassification {
label: string
@@ -94,12 +95,20 @@ function reclassifyWithHfSignal(did: string, rkey: string, classification: Conte
? [...existingSignals, signal]
: existingSignals
+ const tier = tierForScore(row.score, updatedSignals)
+
updateActivity(did, rkey, {
score: row.score,
- tier: row.tier,
+ tier,
breakdown: row.breakdown,
testSignals: JSON.stringify(updatedSignals),
})
+
+ if (tier !== row.tier && _onReclassify) {
+ void _onReclassify(row.uri, tier).catch(err => {
+ console.warn('[hf-classifier] label update failed:', err instanceof Error ? err.message : err)
+ })
+ }
}
let hf: HfInference | null = null
diff --git a/src/lib/scorer.ts b/src/lib/scorer.ts
index 0dac333..ab3efcf 100644
--- a/src/lib/scorer.ts
+++ b/src/lib/scorer.ts
@@ -1,32 +1,16 @@
import type { LabelTier, ScoreBreakdown, ScoreResult } from './types'
import type { MergedScoringInput, UrlResolutionMap, UrlResolutionState } from './scoring-input'
-import { AUTHENTICITY_FAILURE_TIER, COMPLETENESS_WEIGHTS, FOUNDED_DATE_AGE_BUCKETS, SCORE_THRESHOLDS } from './constants'
+import { COMPLETENESS_WEIGHTS, FOUNDED_DATE_AGE_BUCKETS, SCORE_THRESHOLDS } from './constants'
import { evaluateMergedActorAuthenticity } from './scoring-authenticity'
import { validateOrganizationLocationRef } from './location-utils'
import { isConfiguredPdsHost } from './pds-utils'
import { displayNameMatchesWebsiteDomain, normalizePublicWebsiteUrl } from './website-utils'
+/** Scored activity result plus informational validation notes for dashboard display. */
export type ScoreResultWithValidationNotes = ScoreResult & {
validationNotes: string[]
}
-const ZERO_BREAKDOWN: ScoreBreakdown = {
- displayName: 0,
- description: 0,
- organizationType: 0,
- websitePresent: 0,
- websiteResolves: 0,
- websiteMatchesName: 0,
- organizationUrlsPresent: 0,
- organizationUrlsResolve: 0,
- locationValid: 0,
- foundedDateValid: 0,
- foundedDateAge: 0,
- avatar: 0,
- banner: 0,
- trustedPds: 0,
-}
-
// Organization records do not currently cap the number of URL refs in the
// generated lexicon. Keep URL scoring bounded and avoid network calls on the Tap
// ack path.
@@ -143,19 +127,17 @@ export function scoreTrustedPdsBonus(
return isConfiguredPdsHost(actorPdsHost, trustedPdsHosts) ? trustedPdsBonus : 0
}
+/**
+ * Scores a merged actor profile and organization record. Hard test evidence
+ * becomes testSignals and only affects the derived tier; validation notes remain
+ * informational and do not force likely-test.
+ */
export async function scoreActivity(record: MergedScoringInput): Promise {
const authenticity = evaluateMergedActorAuthenticity(record)
- const validationNotes = record.validationNotes ?? []
-
- if (!authenticity.passed) {
- return {
- totalScore: 0,
- tier: AUTHENTICITY_FAILURE_TIER,
- breakdown: ZERO_BREAKDOWN,
- testSignals: authenticity.signals,
- validationNotes,
- }
- }
+ const validationNotes = Array.from(new Set([
+ ...(record.validationNotes ?? []),
+ ...authenticity.validationNotes,
+ ]))
const websiteResolves = scoreWebsiteResolves(record.profileWebsite, record.urlResolution)
const organizationUrlsResolve = scoreOrganizationUrlsResolve(record.urls, record.urlResolution)
@@ -193,24 +175,25 @@ export async function scoreActivity(record: MergedScoringInput): Promise sum + value, 0)
const totalScore = rawScore
- const tier = tierForScore(totalScore)
+ const tier = tierForScore(totalScore, authenticity.testSignals)
return {
totalScore,
tier,
breakdown,
- testSignals: [],
+ testSignals: authenticity.testSignals,
validationNotes,
}
}
+/** Returns the runtime label tier from score plus hard test evidence. */
export function tierForScore(score: number, testSignals: string[] = []): LabelTier {
if (testSignals.length > 0) return 'likely-test'
if (score >= SCORE_THRESHOLDS['high-quality'].min) return 'high-quality'
- if (score >= SCORE_THRESHOLDS.standard.min) return 'standard'
- return 'likely-test'
+ return 'standard'
}
+/** Returns the AT Protocol label identifier for a runtime tier. */
export function labelIdentifierForTier(tier: LabelTier): string {
return tier
}
diff --git a/src/lib/scoring-authenticity.ts b/src/lib/scoring-authenticity.ts
index eac3a15..19e1156 100644
--- a/src/lib/scoring-authenticity.ts
+++ b/src/lib/scoring-authenticity.ts
@@ -1,63 +1,78 @@
import type { MergedScoringInput } from './scoring-input'
-import { AUTHENTICITY_TEXT_PATTERNS, DISPLAY_NAME_AUTHENTICITY_TEXT_PATTERNS } from './constants'
+import {
+ AUTHENTICITY_TEXT_PATTERNS,
+ DESCRIPTION_AUTHENTICITY_TEXT_PATTERNS,
+ DISPLAY_NAME_AUTHENTICITY_TEXT_PATTERNS,
+ SHORT_FIELD_AUTHENTICITY_TEXT_PATTERNS,
+} from './constants'
import { isPlaceholderWebsiteUrl, normalizePublicWebsiteUrl } from './website-utils'
+/** Result of separating hard test evidence from softer data-quality issues. */
export interface AuthenticityGateResult {
+ /** True when no hard test evidence was found. Validation notes do not fail the gate. */
passed: boolean
- signals: string[]
+ /** Hard evidence that should force the likely-test label. */
+ testSignals: string[]
+ /** Data-quality issues that should be shown to operators without changing the tier. */
+ validationNotes: string[]
}
function normalizeText(value: unknown): string {
return typeof value === 'string' ? value.trim().replace(/\s+/g, ' ') : ''
}
+function matchesAnyPattern(value: string, patterns: RegExp[]): boolean {
+ return patterns.some(pattern => pattern.test(value))
+}
+
function hasMeaningfulText(value: unknown, patterns: RegExp[] = AUTHENTICITY_TEXT_PATTERNS): boolean {
const normalized = normalizeText(value)
- return normalized.length > 0 && !patterns.some(pattern => pattern.test(normalized))
+ return normalized.length > 0 && !matchesAnyPattern(normalized, patterns)
}
function hasRepeatedCharacterRun(value: string): boolean {
return /([\p{Letter}\p{Number}])\1{3,}/iu.test(value)
}
-function pushSignal(signals: string[], signal: string): void {
- if (!signals.includes(signal)) signals.push(signal)
+function pushUnique(items: string[], item: string): void {
+ if (!items.includes(item)) items.push(item)
}
function validateWebsite(
value: string | null | undefined,
- invalidUrlSignal: string,
+ invalidUrlNote: string,
placeholderDomainSignal: string,
- signals: string[],
+ testSignals: string[],
+ validationNotes: string[],
): boolean {
const normalized = normalizeText(value)
if (!normalized) return false
if (!normalizePublicWebsiteUrl(normalized)) {
- pushSignal(signals, invalidUrlSignal)
+ pushUnique(validationNotes, invalidUrlNote)
return false
}
if (isPlaceholderWebsiteUrl(normalized)) {
- pushSignal(signals, placeholderDomainSignal)
+ pushUnique(testSignals, placeholderDomainSignal)
return false
}
return true
}
-function validateFoundedDate(value: string | null | undefined, signals: string[]): boolean {
+function validateFoundedDate(value: string | null | undefined, validationNotes: string[]): boolean {
const normalized = normalizeText(value)
if (!normalized) return false
const timestamp = Date.parse(normalized)
if (Number.isNaN(timestamp)) {
- pushSignal(signals, 'foundedDate is invalid')
+ pushUnique(validationNotes, 'foundedDate is invalid')
return false
}
if (timestamp > Date.now()) {
- pushSignal(signals, 'foundedDate cannot be in the future')
+ pushUnique(validationNotes, 'foundedDate cannot be in the future')
return false
}
@@ -66,7 +81,8 @@ function validateFoundedDate(value: string | null | undefined, signals: string[]
function validateOrganizationUrls(
urls: MergedScoringInput['urls'],
- signals: string[],
+ testSignals: string[],
+ validationNotes: string[],
): { hasMeaningfulMetadata: boolean } {
let hasMeaningfulMetadata = false
let hasInvalidUrl = false
@@ -88,7 +104,7 @@ function validateOrganizationUrls(
}
if (label) {
- if (hasMeaningfulText(label)) {
+ if (hasMeaningfulText(label, SHORT_FIELD_AUTHENTICITY_TEXT_PATTERNS)) {
hasMeaningfulMetadata = true
} else {
hasPlaceholderLabel = true
@@ -97,77 +113,87 @@ function validateOrganizationUrls(
}
if (hasInvalidUrl) {
- pushSignal(signals, 'Organization URL must be a public http(s) URL')
+ pushUnique(validationNotes, 'Organization URL must be a public http(s) URL')
}
if (hasPlaceholderDomain) {
- pushSignal(signals, 'Organization URLs use placeholder domains')
+ pushUnique(testSignals, 'Organization URLs use placeholder domains')
}
if (hasPlaceholderLabel) {
- pushSignal(signals, 'Organization URL labels contain placeholder text')
+ pushUnique(validationNotes, 'Organization URL labels contain placeholder text')
}
return { hasMeaningfulMetadata }
}
+/**
+ * Evaluates merged actor metadata before scoring. Hard test evidence is returned
+ * as testSignals and should force likely-test. Softer quality problems are
+ * returned as validationNotes so they can be displayed without changing tiering.
+ */
export function evaluateMergedActorAuthenticity(record: MergedScoringInput): AuthenticityGateResult {
- const signals: string[] = []
+ const testSignals: string[] = []
+ const validationNotes: string[] = []
const canonicalDisplayName = normalizeText(record.displayName)
const hasProfileDisplayName = record.displayNameSource !== 'did'
if (hasProfileDisplayName && canonicalDisplayName && !hasMeaningfulText(canonicalDisplayName, DISPLAY_NAME_AUTHENTICITY_TEXT_PATTERNS)) {
- pushSignal(signals, 'Display name contains placeholder text')
+ pushUnique(testSignals, 'Display name contains placeholder text')
}
if (hasProfileDisplayName && canonicalDisplayName && hasRepeatedCharacterRun(canonicalDisplayName)) {
- pushSignal(signals, 'Display name contains repeated characters')
+ pushUnique(testSignals, 'Display name contains repeated characters')
}
const profileDescription = normalizeText(record.profileDescription)
- if (profileDescription && !hasMeaningfulText(profileDescription)) {
- pushSignal(signals, 'Profile description contains placeholder text')
+ if (profileDescription && !hasMeaningfulText(profileDescription, DESCRIPTION_AUTHENTICITY_TEXT_PATTERNS)) {
+ pushUnique(testSignals, 'Profile description contains placeholder text')
}
const organizationTypeValues = (record.organizationType ?? []).map(normalizeText).filter(Boolean)
- if (organizationTypeValues.some(value => !hasMeaningfulText(value))) {
- pushSignal(signals, 'Organization type contains placeholder text')
+ if (organizationTypeValues.some(value => !hasMeaningfulText(value, SHORT_FIELD_AUTHENTICITY_TEXT_PATTERNS))) {
+ pushUnique(validationNotes, 'Organization type contains placeholder text')
}
const profileWebsite = validateWebsite(
record.profileWebsite,
'Profile website must be a public http(s) URL',
'Profile website uses placeholder domain',
- signals,
+ testSignals,
+ validationNotes,
)
- const organizationUrls = validateOrganizationUrls(record.urls, signals)
- const foundedDate = validateFoundedDate(record.foundedDate, signals)
+ const organizationUrls = validateOrganizationUrls(record.urls, testSignals, validationNotes)
+ const foundedDate = validateFoundedDate(record.foundedDate, validationNotes)
const displayNameIsMeaningful = hasProfileDisplayName && hasMeaningfulText(canonicalDisplayName, DISPLAY_NAME_AUTHENTICITY_TEXT_PATTERNS)
- const organizationTypeIsMeaningful = organizationTypeValues.some(value => hasMeaningfulText(value))
+ const organizationTypeIsMeaningful = organizationTypeValues.some(value => hasMeaningfulText(value, SHORT_FIELD_AUTHENTICITY_TEXT_PATTERNS))
const hasMeaningfulMetadata =
displayNameIsMeaningful ||
- hasMeaningfulText(profileDescription) ||
+ hasMeaningfulText(profileDescription, DESCRIPTION_AUTHENTICITY_TEXT_PATTERNS) ||
organizationTypeIsMeaningful ||
profileWebsite ||
organizationUrls.hasMeaningfulMetadata ||
foundedDate
if (!hasMeaningfulMetadata) {
- pushSignal(signals, 'No meaningful profile or organization metadata remains after normalization')
+ pushUnique(validationNotes, 'No meaningful profile or organization metadata remains after normalization')
if (record.displayNameSource === 'did') {
- pushSignal(signals, 'Display name falls back to the DID and no other meaningful fields are present')
+ pushUnique(validationNotes, 'Display name falls back to the DID and no other meaningful fields are present')
}
}
return {
- passed: signals.length === 0,
- signals,
+ passed: testSignals.length === 0,
+ testSignals,
+ validationNotes,
}
}
+/** Backwards-compatible name for evaluating merged actor authenticity. */
export const assessMergedActorAuthenticity = evaluateMergedActorAuthenticity
+/** Backwards-compatible name for evaluating merged actor authenticity. */
export const evaluateAuthenticityGate = evaluateMergedActorAuthenticity
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 33287c6..fbad899 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -92,7 +92,7 @@ export interface ScoreResult {
totalScore: number // final score: 0-100 completeness plus configured bonuses
tier: LabelTier
breakdown: ScoreBreakdown
- testSignals: string[] // reasons flagged as test data
+ testSignals: string[] // hard evidence that forces the likely-test label
}
export interface ScoreBreakdown {
diff --git a/tests/scorer-tiering.test.ts b/tests/scorer-tiering.test.ts
new file mode 100644
index 0000000..f5e3e25
--- /dev/null
+++ b/tests/scorer-tiering.test.ts
@@ -0,0 +1,77 @@
+import assert from 'node:assert/strict'
+import { test } from 'node:test'
+import { scoreActivity, tierForScore } from '../src/lib/scorer'
+import type { MergedScoringInput } from '../src/lib/scoring-input'
+
+function sparseRecord(overrides: Partial = {}): MergedScoringInput {
+ return {
+ did: 'did:plc:sparsetiering',
+ displayName: 'sparsetiering…',
+ displayNameSource: 'did',
+ profileDisplayName: null,
+ profileDescription: null,
+ profileWebsite: null,
+ validationNotes: [],
+ hasAvatar: false,
+ hasBanner: false,
+ organizationType: [],
+ urls: [],
+ location: null,
+ foundedDate: null,
+ ...overrides,
+ }
+}
+
+function completeRecord(overrides: Partial = {}): MergedScoringInput {
+ return {
+ did: 'did:plc:completetiering',
+ displayName: 'Forest Recovery Collective',
+ displayNameSource: 'profile',
+ profileDisplayName: 'Forest Recovery Collective',
+ profileDescription: 'A community organization restoring native forest habitats.',
+ profileWebsite: 'https://forestrecovery.org',
+ validationNotes: [],
+ hasAvatar: true,
+ hasBanner: true,
+ organizationType: ['nonprofit'],
+ urls: [{ url: 'https://forestrecovery.org/projects', label: 'Projects' }],
+ location: null,
+ foundedDate: '2005-01-01T00:00:00.000Z',
+ ...overrides,
+ }
+}
+
+test('non-test records below the high-quality threshold default to standard', async () => {
+ const result = await scoreActivity(sparseRecord())
+
+ assert.equal(result.totalScore, 0)
+ assert.equal(result.tier, 'standard')
+ assert.deepEqual(result.testSignals, [])
+ assert.ok(result.validationNotes.includes('No meaningful profile or organization metadata remains after normalization'))
+})
+
+test('high-scoring non-test records are high-quality', async () => {
+ const result = await scoreActivity(completeRecord())
+
+ assert.equal(result.tier, 'high-quality')
+ assert.deepEqual(result.testSignals, [])
+ assert.ok(result.totalScore >= 70)
+})
+
+test('hard test signals override score tier without zeroing the score', async () => {
+ const result = await scoreActivity(completeRecord({
+ displayName: 'Test Org',
+ profileDisplayName: 'Test Org',
+ }))
+
+ assert.equal(result.tier, 'likely-test')
+ assert.ok(result.totalScore >= 70)
+ assert.ok(result.testSignals.includes('Display name contains placeholder text'))
+})
+
+test('tierForScore only returns likely-test when hard test evidence exists', () => {
+ assert.equal(tierForScore(0), 'standard')
+ assert.equal(tierForScore(69), 'standard')
+ assert.equal(tierForScore(70), 'high-quality')
+ assert.equal(tierForScore(90, ['actor-pds-test-host: epds1.test.certified.app']), 'likely-test')
+})
diff --git a/tests/scoring-authenticity.test.ts b/tests/scoring-authenticity.test.ts
index 66e0285..7014493 100644
--- a/tests/scoring-authenticity.test.ts
+++ b/tests/scoring-authenticity.test.ts
@@ -47,7 +47,7 @@ test('display-name workflow and test terms fail the authenticity gate', () => {
for (const displayName of names) {
const result = evaluateMergedActorAuthenticity(record({ displayName, profileDisplayName: displayName }))
assert.equal(result.passed, false, displayName)
- assert.ok(result.signals.includes('Display name contains placeholder text'), displayName)
+ assert.ok(result.testSignals.includes('Display name contains placeholder text'), displayName)
}
})
@@ -59,7 +59,18 @@ test('display-name workflow checks do not punish longer descriptive words or non
}))
assert.equal(result.passed, true)
- assert.deepEqual(result.signals, [])
+ assert.deepEqual(result.testSignals, [])
+ assert.deepEqual(result.validationNotes, [])
+})
+
+test('profile descriptions may mention test words without failing authenticity', () => {
+ const result = evaluateMergedActorAuthenticity(record({
+ profileDescription: 'Uganda Rural Development and Training Programme (URDT) is an organization founded to address the missing link in development programmes by merging truly functional education, consciousness raising, skills training, and rural development interventions with the intent of empowering marginalized people living in rural communities in Uganda. Since 1987, URDT has evolved, applied and tested a rural development methodology based on the principles of the creative process and systems thinking. The organization distributes resources such as water pumps to farmers across multiple sub-counties in Uganda.',
+ }))
+
+ assert.equal(result.passed, true)
+ assert.deepEqual(result.testSignals, [])
+ assert.deepEqual(result.validationNotes, [])
})
test('display-name embedded-test checks do not punish normal words', () => {
@@ -74,7 +85,8 @@ test('display-name embedded-test checks do not punish normal words', () => {
for (const displayName of names) {
const result = evaluateMergedActorAuthenticity(record({ displayName, profileDisplayName: displayName }))
assert.equal(result.passed, true, displayName)
- assert.deepEqual(result.signals, [], displayName)
+ assert.deepEqual(result.testSignals, [], displayName)
+ assert.deepEqual(result.validationNotes, [], displayName)
}
})
@@ -91,7 +103,7 @@ test('placeholder profile website domains fail authenticity checks', () => {
for (const profileWebsite of urls) {
const result = evaluateMergedActorAuthenticity(record({ profileWebsite }))
assert.equal(result.passed, false, profileWebsite)
- assert.ok(result.signals.includes('Profile website uses placeholder domain'), profileWebsite)
+ assert.ok(result.testSignals.includes('Profile website uses placeholder domain'), profileWebsite)
}
})
@@ -110,10 +122,39 @@ test('placeholder organization URL domains fail authenticity checks', () => {
urls: [{ url, label: 'Project docs' }],
}))
assert.equal(result.passed, false, url)
- assert.ok(result.signals.includes('Organization URLs use placeholder domains'), url)
+ assert.ok(result.testSignals.includes('Organization URLs use placeholder domains'), url)
}
})
+test('malformed optional fields become validation notes rather than test signals', () => {
+ const result = evaluateMergedActorAuthenticity(record({
+ profileWebsite: 'not a url',
+ organizationType: ['placeholder'],
+ urls: [{ url: 'also not a url', label: 'placeholder' }],
+ foundedDate: 'not a date',
+ }))
+
+ assert.equal(result.passed, true)
+ assert.deepEqual(result.testSignals, [])
+ assert.ok(result.validationNotes.includes('Profile website must be a public http(s) URL'))
+ assert.ok(result.validationNotes.includes('Organization type contains placeholder text'))
+ assert.ok(result.validationNotes.includes('Organization URL must be a public http(s) URL'))
+ assert.ok(result.validationNotes.includes('Organization URL labels contain placeholder text'))
+ assert.ok(result.validationNotes.includes('foundedDate is invalid'))
+})
+
+test('short metadata test words become validation notes rather than test signals', () => {
+ const result = evaluateMergedActorAuthenticity(record({
+ organizationType: ['testing'],
+ urls: [{ url: 'https://forest-recovery.example.coop', label: 'test docs' }],
+ }))
+
+ assert.equal(result.passed, true)
+ assert.deepEqual(result.testSignals, [])
+ assert.ok(result.validationNotes.includes('Organization type contains placeholder text'))
+ assert.ok(result.validationNotes.includes('Organization URL labels contain placeholder text'))
+})
+
test('DID fallback display names are not checked as user-provided display names', () => {
const result = evaluateMergedActorAuthenticity(record({
displayName: 'aaaa12345678…',
@@ -122,7 +163,8 @@ test('DID fallback display names are not checked as user-provided display names'
}))
assert.equal(result.passed, true)
- assert.deepEqual(result.signals, [])
+ assert.deepEqual(result.testSignals, [])
+ assert.deepEqual(result.validationNotes, [])
})
test('display-name repeated character runs fail authenticity checks', () => {
@@ -132,7 +174,7 @@ test('display-name repeated character runs fail authenticity checks', () => {
}))
assert.equal(result.passed, false)
- assert.ok(result.signals.includes('Display name contains repeated characters'))
+ assert.ok(result.testSignals.includes('Display name contains repeated characters'))
})
test('lorem ipsum anywhere in a description fails authenticity checks', () => {
@@ -141,5 +183,5 @@ test('lorem ipsum anywhere in a description fails authenticity checks', () => {
}))
assert.equal(result.passed, false)
- assert.ok(result.signals.includes('Profile description contains placeholder text'))
+ assert.ok(result.testSignals.includes('Profile description contains placeholder text'))
})