Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
038e41b
docs(229): ADR-0007 + implementation plan for subject-risk composite
nikimilenkov Jul 14, 2026
d8ed7c9
feat(db): materialize is_single_offer/is_high_markup on contracts
nikimilenkov Jul 14, 2026
574df1d
feat(db): per-subject risk aggregates on company/authority totals
nikimilenkov Jul 14, 2026
bfa0106
refactor(web): unify single-offer on bids_received=1, read materializ…
nikimilenkov Jul 15, 2026
30fe8cb
feat(web): subject risk indicator on company/authority profiles
nikimilenkov Jul 15, 2026
838c72e
docs(web): methodology entry for composite risk + reword neutrality p…
nikimilenkov Jul 15, 2026
327ed38
feat(db): bound subject-risk aggregates in the integrity gate (#229)
nikimilenkov Jul 15, 2026
184ee18
fix(db): gate is_high_markup on value_flag='ok' to match the contract…
nikimilenkov Jul 15, 2026
7d2bb4a
chore(db): skip the subject-risk integrity check when its columns are…
nikimilenkov Jul 15, 2026
fc46813
docs: describe subject-risk invariants in comments instead of citing …
nikimilenkov Jul 15, 2026
a3e4eff
feat(risk): make the high-markup drill-down traceable
nikimilenkov Jul 16, 2026
82d734a
fix(risk): suppress the natural-person risk aggregate at the data layer
nikimilenkov Jul 16, 2026
5582616
fix(db): move subject-risk columns to a numbered migration (#244 review)
nikimilenkov Jul 16, 2026
5861059
fix(web): subject-risk indicator a11y + wording (#244 review)
nikimilenkov Jul 16, 2026
e43e05d
docs: align ADR-0002 + etl.md with the numbered-migration model
nikimilenkov Jul 16, 2026
4009a17
docs: index the #229 implementation plan (fix docs-integrity gate)
nikimilenkov Jul 17, 2026
6a8e379
docs(risk): correct the contractsBase example (companySlug strips the…
nikimilenkov Jul 20, 2026
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
57 changes: 57 additions & 0 deletions apps/web/app/components/SubjectRiskIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Link } from 'react-router';
import { count, plural, pct } from '@sigma/shared';
import { Callout } from './ui';
import type { RiskBandKey, RiskComponentKey, SubjectRiskView } from '../lib/subjectRisk';

const BAND_LABEL: Record<RiskBandKey, string> = {
few: 'Малко индикатори',
some: 'Единични индикатори',
many: 'Множество индикатори',
most: 'Много индикатори — заслужава преглед',
};

const COMPONENT_LABEL: Record<RiskComponentKey, string> = {
single_offer: 'Една оферта',
high_markup: 'Високо оскъпяване',
};

// Each component's „виж договорите" links to exactly the contracts it counts, so every number on the
// page is traceable (the drill-down control, M7). The value is the same predicate the rollup
// materializes: bids=1 ⇒ c.bids_received = 1, markup=high ⇒ c.is_high_markup = 1 (@sigma/db filters).
const COMPONENT_FILTER: Record<RiskComponentKey, string> = {
single_offer: 'bids=1',
high_markup: 'markup=high',
};

// Subject-level risk. Rendered ONLY when buildSubjectRisk returned a view (natural persons and thin
// samples are already suppressed upstream). The framing, the band, the counts and the drill-down are one
// atomic block (M8) — the disclaimer never renders apart from the number, and the caller keeps this out
// of <meta>/OG so it can't become a search snippet.
export function SubjectRiskIndicator({
risk,
contractsBase,
}: {
risk: SubjectRiskView;
contractsBase: string; // e.g. '/contracts?bidder=eik:123' — already carries a query string
}) {
return (
<Callout variant="neutral">
<p className="m-0">
Обобщава колко от договорите на субекта имат рискови признаци. Неутрален индикатор — не
оценява процедурите и не маркира субекта като нарушител. Изводите прави потребителят.{' '}
<Link to="/methodology#risk">Методология</Link>.
</p>
<p className="subject-risk-band">{BAND_LABEL[risk.band]}</p>
<ul className="subject-risk-list">
{risk.components.map((c) => (
<li key={c.key}>
<strong>{COMPONENT_LABEL[c.key]}:</strong> {count(c.k)} от {count(c.n)}{' '}
{plural(c.n, 'договор', 'договора')}
{c.valueShare != null ? <> · {pct(c.valueShare)} от стойността</> : null} ·{' '}
<Link to={`${contractsBase}&${COMPONENT_FILTER[c.key]}`}>виж договорите</Link>
</li>
))}
</ul>
</Callout>
);
}
2 changes: 1 addition & 1 deletion apps/web/app/components/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export function Callout({
children,
}: {
title?: ReactNode;
variant?: 'warning';
variant?: 'warning' | 'neutral';
children: ReactNode;
}) {
return (
Expand Down
2 changes: 2 additions & 0 deletions apps/web/app/lib/csv-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ describe('isUnfilteredCsvExport', () => {
['bidder', { bidder: 'acme' }],
['q', { q: 'rail' }],
['bids', { bids: 'one' }],
['markup', { markup: 'high' }],
['companies.kinds', { kinds: ['company'] }],
['companies.countBucket', { countBucket: '2-5' }],
['authorities.types', { types: ['municipality'] }],
Expand Down Expand Up @@ -335,6 +336,7 @@ describe('isUnfilteredCsvExport', () => {
bidder: 'acme',
q: 'rail',
bids: 'one',
markup: 'high',
types: ['municipality'],
kinds: ['company'],
countBucket: '2-5',
Expand Down
11 changes: 10 additions & 1 deletion apps/web/app/lib/csv-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,16 @@ const ARRAY_FILTERS = ['years', 'sectors', 'procedureGroups', 'kinds', 'types']
// was misclassified as unfiltered and served from / written to the shared unfiltered cache object —
// a cache-poisoning variant of #56/#122 on top of the wrong-data bug (#138). hasScalarFilter treats
// 'one' as set and null as absent, so it slots in cleanly. Other routes simply never carry the key.
const SCALAR_FILTERS = ['valueBucket', 'eu', 'authority', 'bidder', 'countBucket', 'bids'] as const;
// `markup` ('high' | null) is response-affecting exactly like `bids` — same #138 cache-poisoning class.
const SCALAR_FILTERS = [
'valueBucket',
'eu',
'authority',
'bidder',
'countBucket',
'bids',
'markup',
] as const;
const FILENAMES = {
contracts: 'sigma-contracts.csv',
companies: 'sigma-companies.csv',
Expand Down
2 changes: 2 additions & 0 deletions apps/web/app/lib/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export function contractListFilters(sp: URLSearchParams) {
bidder: sp.get('bidder'),
q: sp.get('q'),
bids: (sp.get('bids') === '1' ? 'one' : null) as 'one' | null,
markup: (sp.get('markup') === 'high' ? 'high' : null) as 'high' | null,
};
}

Expand Down Expand Up @@ -190,6 +191,7 @@ export const PARAM_ORDER = [
'funding',
'eu',
'bids', // /contracts single-bid filter
'markup', // /contracts high-markup filter (risk drill-down)
'value',
'authority',
'bidder',
Expand Down
1 change: 1 addition & 0 deletions apps/web/app/lib/query-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const CANONICAL_QUERY_PARAMS = new Set([
'bidder',
'bids', // single-bid filter — changes the result set + totals
'center',
'markup', // /contracts: c.is_high_markup = 1 — changes the result set + totals (must stay keyed so the risk drill-down link survives withParams)
'count',
'cursor',
'eu',
Expand Down
104 changes: 56 additions & 48 deletions apps/web/app/lib/riskLogic.test.ts
Original file line number Diff line number Diff line change
@@ -1,77 +1,85 @@
import { describe, it, expect } from 'vitest';
import { evaluateRiskIndicators } from './riskLogic';
import { evaluateRiskIndicators, type RiskFlagInput } from './riskLogic';

function buildContract(overrides: any = {}): any {
// evaluateRiskIndicators reads the materialized flags (isSingleOffer/isHighMarkup) — the same columns the
// subject-risk rollups aggregate (#229) — and deltaPct only for the displayed %. A ContractDetail
// satisfies RiskFlagInput structurally.
function buildContract(overrides: Partial<RiskFlagInput> = {}): RiskFlagInput {
return {
bidsReceived: 2,
bidsRejected: 0,
isSingleOffer: false,
isHighMarkup: false,
euFunded: false,
dateSuspect: false,
value: {
deltaPct: 0.1,
suspect: false,
},
value: { deltaPct: 0.1, suspect: false },
...overrides,
};
}

describe('evaluateRiskIndicators', () => {
it('returns empty when no risks are present', () => {
const flags = evaluateRiskIndicators(buildContract());
expect(flags).toHaveLength(0);
it('returns empty when no flags are set', () => {
expect(evaluateRiskIndicators(buildContract())).toEqual([]);
});

describe('Competition heuristics', () => {
it('triggers NO_COMPETITION when exactly 1 bid is admitted (non-EU)', () => {
const contract = buildContract({ bidsReceived: 3, bidsRejected: 2, euFunded: false });
const flags = evaluateRiskIndicators(contract);
expect(flags).toEqual([{ type: 'no_competition' }]);
describe('competition', () => {
it('flags no_competition when single-offer and not EU funded', () => {
expect(
evaluateRiskIndicators(buildContract({ isSingleOffer: true, euFunded: false })),
).toEqual([{ type: 'no_competition' }]);
});

it('triggers EU_NO_COMPETITION when exactly 1 bid is admitted and EU funded', () => {
const contract = buildContract({ bidsReceived: 1, bidsRejected: 0, euFunded: true });
const flags = evaluateRiskIndicators(contract);
expect(flags).toEqual([{ type: 'eu_no_competition' }]);
it('flags eu_no_competition when single-offer and EU funded', () => {
expect(
evaluateRiskIndicators(buildContract({ isSingleOffer: true, euFunded: true })),
).toEqual([{ type: 'eu_no_competition' }]);
});

it('does not trigger competition flags when > 1 bid is admitted', () => {
const contract = buildContract({ bidsReceived: 2, bidsRejected: 0 });
const flags = evaluateRiskIndicators(contract);
expect(flags).not.toContainEqual({ type: 'no_competition' });
expect(flags).not.toContainEqual({ type: 'eu_no_competition' });
it('does not flag competition when not single-offer', () => {
expect(evaluateRiskIndicators(buildContract({ isSingleOffer: false }))).toEqual([]);
});

it('does not flag when the single-offer flag is null (unknown bid count)', () => {
// The flag is the sole input — unified on bids_received = 1; there is no bid-count arithmetic here.
expect(evaluateRiskIndicators(buildContract({ isSingleOffer: null }))).toEqual([]);
});
});

describe('Markup heuristics', () => {
it('triggers HIGH_MARKUP when deltaPct > 20%', () => {
const contract = buildContract({ value: { deltaPct: 0.21, suspect: false } });
const flags = evaluateRiskIndicators(contract);
expect(flags).toContainEqual({ type: 'high_markup', deltaPct: 0.21 });
describe('markup', () => {
it('flags high_markup when the flag is set, carrying deltaPct for display', () => {
expect(
evaluateRiskIndicators(
buildContract({ isHighMarkup: true, value: { deltaPct: 0.21, suspect: false } }),
),
).toEqual([{ type: 'high_markup', deltaPct: 0.21 }]);
});

it('does not flag high_markup when the flag is false, even with a high deltaPct', () => {
expect(
evaluateRiskIndicators(
buildContract({ isHighMarkup: false, value: { deltaPct: 0.5, suspect: false } }),
),
).toEqual([]);
});

it('does not trigger HIGH_MARKUP when deltaPct is exactly 20% or less', () => {
const contract1 = buildContract({ value: { deltaPct: 0.2, suspect: false } });
const contract2 = buildContract({ value: { deltaPct: 0.19, suspect: false } });
expect(evaluateRiskIndicators(contract1)).not.toContainEqual(
expect.objectContaining({ type: 'high_markup' }),
);
expect(evaluateRiskIndicators(contract2)).not.toContainEqual(
expect.objectContaining({ type: 'high_markup' }),
);
it('does not flag high_markup when the flag is set but deltaPct is null (no NaN%)', () => {
expect(
evaluateRiskIndicators(
buildContract({ isHighMarkup: true, value: { deltaPct: null, suspect: false } }),
),
).toEqual([]);
});
});

describe('Anomaly heuristics', () => {
it('triggers ANOMALIES when date is suspect', () => {
const contract = buildContract({ dateSuspect: true });
const flags = evaluateRiskIndicators(contract);
expect(flags).toContainEqual({ type: 'anomalies' });
describe('anomalies', () => {
it('flags anomalies when the date is suspect', () => {
expect(evaluateRiskIndicators(buildContract({ dateSuspect: true }))).toEqual([
{ type: 'anomalies' },
]);
});

it('triggers ANOMALIES when value is suspect', () => {
const contract = buildContract({ value: { deltaPct: 0, suspect: true } });
const flags = evaluateRiskIndicators(contract);
expect(flags).toContainEqual({ type: 'anomalies' });
it('flags anomalies when the value is suspect', () => {
expect(
evaluateRiskIndicators(buildContract({ value: { deltaPct: 0, suspect: true } })),
).toEqual([{ type: 'anomalies' }]);
});
});
});
27 changes: 15 additions & 12 deletions apps/web/app/lib/riskLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,28 @@ export interface RiskIndicatorResult {
deltaPct?: number;
}

export function evaluateRiskIndicators(contract: ContractDetail): RiskIndicatorResult[] {
/** The fields evaluateRiskIndicators actually reads — a ContractDetail satisfies this structurally.
* isSingleOffer/isHighMarkup are the materialized flags (scripts/precompute.sql), so the per-contract
* display and the subject-risk rollups share ONE definition, unified on `bids_received = 1`. */
export type RiskFlagInput = Pick<
ContractDetail,
'isSingleOffer' | 'isHighMarkup' | 'euFunded' | 'dateSuspect'
> & { value: Pick<ContractDetail['value'], 'deltaPct' | 'suspect'> };

export function evaluateRiskIndicators(contract: RiskFlagInput): RiskIndicatorResult[] {
const flags: RiskIndicatorResult[] = [];

const admitted =
contract.bidsReceived != null ? contract.bidsReceived - (contract.bidsRejected || 0) : null;

if (admitted === 1) {
if (contract.euFunded) {
flags.push({ type: 'eu_no_competition' });
} else {
flags.push({ type: 'no_competition' });
}
if (contract.isSingleOffer) {
flags.push({ type: contract.euFunded ? 'eu_no_competition' : 'no_competition' });
}

if (contract.value?.deltaPct != null && contract.value.deltaPct > 0.2) {
// isHighMarkup is the materialized flag; deltaPct is still read for the displayed %. It is NULL on the
// suspect rows where the flag is also null, so the `!= null` guard stops a stale flag rendering `NaN%`.
if (contract.isHighMarkup && contract.value.deltaPct != null) {
flags.push({ type: 'high_markup', deltaPct: contract.value.deltaPct });
}

if (contract.dateSuspect || contract.value?.suspect) {
if (contract.dateSuspect || contract.value.suspect) {
flags.push({ type: 'anomalies' });
}

Expand Down
88 changes: 88 additions & 0 deletions apps/web/app/lib/subjectRisk.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest';
import type { SubjectRiskAggregate } from '@sigma/api-contract';
import { buildSubjectRisk } from './subjectRisk';

function agg(o: Partial<SubjectRiskAggregate> = {}): SubjectRiskAggregate {
return {
singleOfferK: null,
singleOfferN: null,
singleOfferValueShare: null,
highMarkupK: null,
highMarkupN: null,
highMarkupValueShare: null,
...o,
};
}

describe('buildSubjectRisk', () => {
it('suppresses everything for a natural-person profile (M9)', () => {
expect(
buildSubjectRisk(agg({ singleOfferK: 5, singleOfferN: 5 }), { isNaturalPerson: true }),
).toBeNull();
});

it('returns null when there is no aggregate row', () => {
expect(buildSubjectRisk(null, { isNaturalPerson: false })).toBeNull();
});

it('suppresses when no component has enough assessable contracts (min-N, M3)', () => {
expect(
buildSubjectRisk(agg({ singleOfferK: 4, singleOfferN: 4, highMarkupK: 2, highMarkupN: 2 }), {
isNaturalPerson: false,
}),
).toBeNull();
});

it('reports a component exactly at the min-N boundary (n = 5)', () => {
expect(
buildSubjectRisk(agg({ singleOfferK: 1, singleOfferN: 5 }), { isNaturalPerson: false }),
).toEqual({
composite: 0.2,
band: 'some',
components: [{ key: 'single_offer', k: 1, n: 5, countShare: 0.2, valueShare: null }],
});
});

it('bands a zero composite as „few"', () => {
expect(
buildSubjectRisk(agg({ singleOfferK: 0, singleOfferN: 5 }), { isNaturalPerson: false })?.band,
).toBe('few');
});

it('bands a full composite as „most"', () => {
expect(
buildSubjectRisk(agg({ singleOfferK: 5, singleOfferN: 5 }), { isNaturalPerson: false })?.band,
).toBe('most');
});

it('averages reportable components for the composite (count-weighted)', () => {
// single-offer 5/5 = 1.0, high-markup 0/5 = 0.0 → composite 0.5 → „many" (< 0.55).
const view = buildSubjectRisk(
agg({ singleOfferK: 5, singleOfferN: 5, highMarkupK: 0, highMarkupN: 5 }),
{ isNaturalPerson: false },
);
expect(view?.composite).toBe(0.5);
expect(view?.band).toBe('many');
expect(view?.components).toHaveLength(2);
});

it('drops a thin component from the composite but keeps the reportable one', () => {
// single-offer n=5 reportable; high-markup n=3 dropped → composite is single-offer only.
const view = buildSubjectRisk(
agg({ singleOfferK: 3, singleOfferN: 5, highMarkupK: 3, highMarkupN: 3 }),
{ isNaturalPerson: false },
);
expect(view?.components).toEqual([
{ key: 'single_offer', k: 3, n: 5, countShare: 0.6, valueShare: null },
]);
expect(view?.composite).toBe(0.6);
});

it('passes the value share through unchanged', () => {
const view = buildSubjectRisk(
agg({ singleOfferK: 3, singleOfferN: 5, singleOfferValueShare: 0.42 }),
{ isNaturalPerson: false },
);
expect(view?.components[0]?.valueShare).toBe(0.42);
});
});
Loading