fix: reconcile physique targeting for queued posts

This commit is contained in:
jjbrunton committed 2026-08-10 18:23:23 +01:00
1 parent d0a8bd0e2f
commit d8d635e1ee
10 files changed
+250 -12

No files matched your search

+1 -1
View File
@@ -37,7 +37,7 @@ Never document real values. `.env` is ignored and may contain production credent
- `filterSellers`, `sellerKeywords`, `sellerDomains`: seller policy inputs; see also the `conversations` section, whose off-platform kill is independent of this flag. The defaults treat Telegram references, including `t.me` and `telegram.me` links, as seller signals alongside paid-content platforms and payment prompts. - `filterSellers`, `sellerKeywords`, `sellerDomains`: seller policy inputs; see also the `conversations` section, whose off-platform kill is independent of this flag. The defaults treat Telegram references, including `t.me` and `telegram.me` links, as seller signals alongside paid-content platforms and payment prompts.
- `prioritizeRecentPosts`, `recentWindowMinutes`: recency/fallback behavior. - `prioritizeRecentPosts`, `recentWindowMinutes`: recency/fallback behavior.
- `maxReadyCandidates`: hard ceiling for the active candidate target; valid range 1–500. Runtime lowers the actual target to what authenticated running accounts can send within `recentWindowMinutes` under current rolling-hour and message-spacing limits. - `maxReadyCandidates`: hard ceiling for the active candidate target; valid range 1–500. Runtime lowers the actual target to what authenticated running accounts can send within `recentWindowMinutes` under current rolling-hour and message-spacing limits.
- `physiqueTargeting`: optional image-candidate filter with `enabled`, inclusive `minScore`, and inclusive `maxScore` values from 1 to 10. It is disabled by default. When enabled, a model score outside the range or an image without enough visible body evidence to score is rejected before opener generation. The setting is hot-read by existing account runtimes; chat sources are unaffected. - `physiqueTargeting`: optional image-candidate filter with `enabled`, inclusive `minScore`, and inclusive `maxScore` values from 1 to 10. It is disabled by default. When enabled, a model score outside the range or an image without enough visible body evidence to score is rejected before opener generation. Saving the setting also reconciles prepared image candidates in the ready and delivery-retry stages: candidates that no longer qualify are rejected, while retained candidates receive the newly applied range snapshot shown in the funnel. A candidate already leased for sending is not changed mid-send. The setting is hot-read by existing account runtimes; chat sources are unaffected.
Subreddit `type` is `image` or `chat`. Conversation policy contains `mode`, `maxAutomatedTurns`, and `rapportTurnsBeforeMediaAsk`; hybrid rapport turns must be lower than the maximum. Subreddit `type` is `image` or `chat`. Conversation policy contains `mode`, `maxAutomatedTurns`, and `rapportTurnsBeforeMediaAsk`; hybrid rapport turns must be lower than the maximum.
+2 -2
View File
@@ -33,12 +33,12 @@ A screening worker leases one discovered candidate. `MonitorService.screenCandid
1. Global processed-post and recipient-history checks. 1. Global processed-post and recipient-history checks.
2. For chat sources, self-declared gender and adult age parsing. 2. For chat sources, self-declared gender and adult age parsing.
3. Seller evidence from the post, profile/about, and recent activity. 3. Seller evidence from the post, profile/about (including structured social links when exposed and narrow NSFW link-funnel language), and recent activity.
4. Source-specific preparation in `ProcessorService`. 4. Source-specific preparation in `ProcessorService`.
For image posts, preparation downloads and validates the image, detects target gender, and performs model analysis. The analysis includes a separate 1–10 visible-physique score only when enough of the body is visible. If physique targeting is enabled, an unknown score or a score outside the configured inclusive range becomes an explicit rejection before strategy selection and opener generation. Passing image posts then select an image strategy and generate an opener. Chat posts do not use physique targeting; they select a chat strategy and generate from the post text. Both paths apply outbound safety checks and return either a prepared immutable result or an explicit skip reason. For image posts, preparation downloads and validates the image, detects target gender, and performs model analysis. The analysis includes a separate 1–10 visible-physique score only when enough of the body is visible. If physique targeting is enabled, an unknown score or a score outside the configured inclusive range becomes an explicit rejection before strategy selection and opener generation. Passing image posts then select an image strategy and generate an opener. Chat posts do not use physique targeting; they select a chat strategy and generate from the post text. Both paths apply outbound safety checks and return either a prepared immutable result or an explicit skip reason.
Terminal results become rejected candidates. Rate limits and transient failures become retryable candidates. Prepared candidates become ready; the persisted snapshot includes the opener, grounded image analysis, attractiveness and physique scores, the physique range used, source-image URL, and experiment/model/tone metadata. This lets a different account deliver without repeating AI work and lets the authenticated operator inspect the exact preparation context. Terminal results become rejected candidates. Rate limits and transient failures become retryable candidates. Prepared candidates become ready; the persisted snapshot includes the opener, grounded image analysis, attractiveness and physique scores, the physique range used, source-image URL, and experiment/model/tone metadata. This lets a different account deliver without repeating AI work and lets the authenticated operator inspect the exact preparation context. Saving a physique-range change transactionally re-evaluates prepared image candidates in ready and delivery-retry stages, rejects those outside the new range, and refreshes the displayed range snapshot for retained candidates. A candidate already leased for sending is left unchanged until that send resolves.
### 3. Dispatch ### 3. Dispatch
+1 -1
View File
@@ -32,7 +32,7 @@ Configured subreddits have a source type:
Image analysis may produce a subjective 1–10 visible-physique score. It is not a medical assessment and remains unknown when the image does not show enough of the body for a grounded judgment. Physique targeting is disabled by default. When an operator enables an inclusive minimum/maximum range, image candidates outside that range—and unscorable image candidates—are rejected before opener generation; chat candidates are unaffected. Image analysis may produce a subjective 1–10 visible-physique score. It is not a medical assessment and remains unknown when the image does not show enough of the body for a grounded judgment. Physique targeting is disabled by default. When an operator enables an inclusive minimum/maximum range, image candidates outside that range—and unscorable image candidates—are rejected before opener generation; chat candidates are unaffected.
Seller filtering combines the candidate post with Reddit profile/about and recent activity evidence. The default policy treats Telegram names and links as seller signals alongside paid-content platforms and payment prompts. The configured keyword/domain lists are policy inputs, not a substitute for the surrounding screening flow. A profile lookup failure is not itself proof that a person is a seller; network and rate-limit failures must remain distinguishable from a rejection. Seller filtering combines the candidate post with Reddit profile/about, structured social links when Reddit exposes them, and recent activity evidence. Because Reddit's legacy profile response can omit a social-link button that is visible in its UI, an NSFW bio is also rejected when it combines an explicit link call-to-action with a promise of more or exclusive personal content. The default policy treats Telegram names and links as seller signals alongside paid-content platforms and payment prompts. The configured keyword/domain lists are policy inputs, not a substitute for the surrounding screening flow. A profile lookup failure is not itself proof that a person is a seller; network and rate-limit failures must remain distinguishable from a rejection.
The system must not manufacture personal facts. Generated messages must not claim shared location or proximity, invite an in-person meeting, invent identity/history, or ignore a conversation boundary. The final outbound safety check is intentionally separate from prompt wording. The system must not manufacture personal facts. Generated messages must not claim shared location or proximity, invite an in-person meeting, invent identity/history, or ignore a conversation boundary. The final outbound safety check is intentionally separate from prompt wording.
@@ -1,5 +1,7 @@
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import type { PhysiqueTargeting } from '../config/index.js';
import type { RedditPost } from '../reddit/api.js'; import type { RedditPost } from '../reddit/api.js';
import { evaluatePhysiqueTargeting } from '../services/physique-targeting.js';
import type { ProcessedPost } from '../services/processor.js'; import type { ProcessedPost } from '../services/processor.js';
export type CandidateStatus = export type CandidateStatus =
@@ -47,6 +49,11 @@ export interface CandidateEnqueueResult {
deferredAtCapacity: number; deferredAtCapacity: number;
} }
export interface PhysiqueTargetingReconciliation {
rejected: number;
retained: number;
}
const ACTIVE_STATUSES: CandidateStatus[] = ['discovered', 'screening', 'ready', 'sending', 'retryable']; const ACTIVE_STATUSES: CandidateStatus[] = ['discovered', 'screening', 'ready', 'sending', 'retryable'];
/** Global, durable funnel from Reddit listing discovery through outbound delivery. */ /** Global, durable funnel from Reddit listing discovery through outbound delivery. */
@@ -243,6 +250,54 @@ export class CandidateQueueRepository {
`).run(now, now + sendingDelayMs, now).changes; `).run(now, now + sendingDelayMs, now).changes;
} }
/** Re-evaluates prepared image candidates that have not yet been leased for sending. */
reconcilePhysiqueTargeting(
targeting: PhysiqueTargeting,
now: number = Date.now(),
): PhysiqueTargetingReconciliation {
const rows = this.db.prepare(`
SELECT post_id, prepared_json FROM candidate_queue
WHERE source_type = 'image'
AND status IN ('ready', 'retryable')
AND prepared_json IS NOT NULL
`).all() as Array<{ post_id: string; prepared_json: string }>;
const preparedRows = rows.map((row) => ({
postId: row.post_id,
prepared: JSON.parse(row.prepared_json) as ProcessedPost,
}));
const reject = this.db.prepare(`
UPDATE candidate_queue
SET status = 'rejected', last_error = ?, updated_at = ?,
lease_owner = NULL, lease_expires_at = NULL, assigned_account_id = NULL
WHERE post_id = ? AND status IN ('ready', 'retryable')
`);
const retain = this.db.prepare(`
UPDATE candidate_queue
SET prepared_json = ?, updated_at = ?
WHERE post_id = ? AND status IN ('ready', 'retryable')
`);
return this.db.transaction(() => {
const result: PhysiqueTargetingReconciliation = { rejected: 0, retained: 0 };
for (const row of preparedRows) {
const score = typeof row.prepared.physiqueScore === 'number'
? row.prepared.physiqueScore
: null;
const decision = evaluatePhysiqueTargeting(score, targeting);
if (!decision.accepted) {
result.rejected += reject.run(decision.reason, now, row.postId).changes;
continue;
}
const refreshed: ProcessedPost = {
...row.prepared,
physiqueTargeting: { ...targeting },
};
result.retained += retain.run(JSON.stringify(refreshed), now, row.postId).changes;
}
return result;
})();
}
expireStale(cutoff: number, now: number = Date.now()): number { expireStale(cutoff: number, now: number = Date.now()): number {
return this.db.prepare(` return this.db.prepare(`
UPDATE candidate_queue UPDATE candidate_queue
+9
View File
@@ -453,11 +453,20 @@ async function runConcurrentAccountRuntimes(app: AppContainer): Promise<void> {
const workerInstance = `candidate-funnel-${process.pid}-${Date.now()}`; const workerInstance = `candidate-funnel-${process.pid}-${Date.now()}`;
const leaseMs = 5 * 60_000; const leaseMs = 5 * 60_000;
const recovered = candidateQueueRepo.recoverAllLeases(); const recovered = candidateQueueRepo.recoverAllLeases();
const physiqueReconciliation = candidateQueueRepo.reconcilePhysiqueTargeting(
config.reddit.physiqueTargeting,
);
app.recipientDeduplicator.recoverStaleReservations(Date.now() - 10 * 60_000); app.recipientDeduplicator.recoverStaleReservations(Date.now() - 10 * 60_000);
logger.info( logger.info(
`Running ${accountRuntimeManager.list().length} account runtime(s) with durable candidate funnel` `Running ${accountRuntimeManager.list().length} account runtime(s) with durable candidate funnel`
+ (recovered > 0 ? `; recovered ${recovered} interrupted candidate(s)` : ''), + (recovered > 0 ? `; recovered ${recovered} interrupted candidate(s)` : ''),
); );
if (physiqueReconciliation.rejected > 0 || physiqueReconciliation.retained > 0) {
logger.info(
`Reconciled prepared image candidates against physique targeting: `
+ `${physiqueReconciliation.retained} retained, ${physiqueReconciliation.rejected} rejected`,
);
}
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
let shuttingDown = false; let shuttingDown = false;
const controller = new AbortController(); const controller = new AbortController();
+56 -3
View File
@@ -106,6 +106,52 @@ function joinText(values: unknown[]): string {
return values.filter((value): value is string => typeof value === 'string').join(' '); return values.filter((value): value is string => typeof value === 'string').join(' ');
} }
function recordValue(value: unknown, key: string): unknown {
return value && typeof value === 'object' ? (value as Record<string, unknown>)[key] : undefined;
}
/** Flatten Reddit's structured profile-link payloads across legacy/current field naming. */
function socialLinkText(userData: unknown): string {
const subreddit = recordValue(userData, 'subreddit');
const candidates = [
recordValue(userData, 'social_links'),
recordValue(userData, 'socialLinks'),
recordValue(subreddit, 'social_links'),
recordValue(subreddit, 'socialLinks'),
];
const values: unknown[] = [];
for (const candidate of candidates) {
if (!Array.isArray(candidate)) continue;
for (const link of candidate) {
values.push(
recordValue(link, 'title'),
recordValue(link, 'handle'),
recordValue(link, 'type'),
recordValue(link, 'url'),
recordValue(link, 'outbound_url'),
recordValue(link, 'outboundUrl'),
);
}
}
return joinText(values);
}
/**
* Catch a narrow paid-content funnel that deliberately keeps the platform name
* out of the bio. Requiring an NSFW profile, an explicit link CTA, and a
* promise of more/exclusive personal content avoids treating ordinary links as
* seller evidence.
*/
function findProfileFunnel(text: string, isNsfw: boolean): string | null {
if (!isNsfw) return null;
const normalized = text.toLocaleLowerCase().replace(/[\u2018\u2019]/g, "'");
const linkCallToAction = /\b(?:hit|check|tap|click|visit|follow)\s+(?:the|my)\s+link\b/;
const contentPromise = /\b(?:see|get|show(?:ing)?|watch)\s+(?:a\s+)?(?:lot\s+)?more(?:\s+of\s+me)?\b|\bexclusive\s+(?:content|pics?|photos?|videos?)\b/;
return linkCallToAction.test(normalized) && contentPromise.test(normalized)
? 'NSFW profile links to more or exclusive personal content'
: null;
}
/** Match configured seller signals in user-controlled text. */ /** Match configured seller signals in user-controlled text. */
export function findSellerSignal( export function findSellerSignal(
text: string, text: string,
@@ -422,10 +468,12 @@ export class RedditApiClient {
const userData = response.data?.data; const userData = response.data?.data;
if (userData) { if (userData) {
const subreddit = userData.subreddit;
const profileText = joinText([ const profileText = joinText([
userData.subreddit?.public_description || '', subreddit?.public_description || '',
userData.subreddit?.title || '', subreddit?.title || '',
userData.subreddit?.display_name_prefixed || '', subreddit?.display_name_prefixed || '',
socialLinkText(userData),
]); ]);
const profileSignal = findSellerSignal(profileText, sellerKeywords, sellerDomains); const profileSignal = findSellerSignal(profileText, sellerKeywords, sellerDomains);
if (profileSignal) { if (profileSignal) {
@@ -434,6 +482,11 @@ export class RedditApiClient {
); );
return true; return true;
} }
const funnel = findProfileFunnel(profileText, subreddit?.over_18 === true);
if (funnel) {
this.logger.info(`u/${username} flagged as seller profile (matched funnel: "${funnel}")`);
return true;
}
} }
} catch (error) { } catch (error) {
const rateLimitError = this.toRateLimitError(error); const rateLimitError = this.toRateLimitError(error);
+11
View File
@@ -175,6 +175,17 @@ export function createConfigRouter(ctx: WebContext): Router {
configStore.save(ctx.config); configStore.save(ctx.config);
if (parsedPhysiqueTargeting?.success && ctx.services.candidateQueue) {
const reconciliation = ctx.services.candidateQueue.reconcilePhysiqueTargeting(
parsedPhysiqueTargeting.data,
);
ctx.activity.push(
'info',
`Physique targeting reconciled ${reconciliation.retained} prepared image candidate(s)`
+ ` and rejected ${reconciliation.rejected}`,
);
}
ctx.getOnConfigChange()?.(ctx.runtimeConfig); ctx.getOnConfigChange()?.(ctx.runtimeConfig);
ctx.activity.push('info', 'Config updated via dashboard'); ctx.activity.push('info', 'Config updated via dashboard');
+52 -2
View File
@@ -26,13 +26,13 @@ function post(id: string, author: string, created = 100): RedditPost {
}; };
} }
function prepared(candidate: RedditPost): ProcessedPost { function prepared(candidate: RedditPost, physiqueScore: number | null = 5): ProcessedPost {
return { return {
post: candidate, post: candidate,
imageAnalysis: 'analysis', imageAnalysis: 'analysis',
detectedGender: 'female', detectedGender: 'female',
attractivenessScore: 64, attractivenessScore: 64,
physiqueScore: 5, physiqueScore,
physiqueTargeting: { enabled: true, minScore: 3, maxScore: 7 }, physiqueTargeting: { enabled: true, minScore: 3, maxScore: 7 },
sourceImageUrl: 'https://i.redd.it/example.jpg', sourceImageUrl: 'https://i.redd.it/example.jpg',
generatedMessage: 'prepared message', generatedMessage: 'prepared message',
@@ -180,6 +180,56 @@ describe('CandidateQueueRepository', () => {
}); });
}); });
it('reconciles ready and retryable images when the physique range changes', () => {
queue.enqueue([
post('inside', 'alice', 300),
post('outside', 'bob', 200),
post('retry', 'carol', 100),
], 'image', 1, 1_000);
for (const worker of ['screen-1', 'screen-2', 'screen-3']) {
const candidate = queue.claimForScreening(worker, 100, 1_010)!;
const score = candidate.postId === 'outside' ? 7 : 4;
queue.markReady(candidate.postId, worker, prepared(candidate.post, score), 1_011);
}
const retry = queue.claimReady('account-a', 'send', 100, 1_012)!;
expect(retry.postId).toBe('inside');
queue.deferDelivery(retry.postId, 'send', 'rate limited', 2_000, 1_013);
expect(queue.reconcilePhysiqueTargeting(
{ enabled: true, minScore: 1, maxScore: 6 },
1_020,
)).toEqual({ retained: 2, rejected: 1 });
const active = queue.listActive();
expect(active.map((candidate) => candidate.postId)).toEqual(['inside', 'retry']);
expect(active.every((candidate) => (
candidate.prepared?.physiqueTargeting?.enabled === true
&& candidate.prepared.physiqueTargeting.maxScore === 6
))).toBe(true);
expect(queue.getStats().byStatus.rejected).toBe(1);
});
it('fails closed for an unscored prepared image but does not touch a leased send', () => {
queue.enqueue([
post('unscored', 'alice', 200),
post('sending', 'bob', 100),
], 'image', 1, 1_000);
for (const worker of ['screen-1', 'screen-2']) {
const candidate = queue.claimForScreening(worker, 100, 1_010)!;
queue.markReady(candidate.postId, worker, prepared(candidate.post, null), 1_011);
}
const sending = queue.claimReady('account-a', 'send', 100, 1_012)!;
expect(queue.reconcilePhysiqueTargeting(
{ enabled: true, minScore: 1, maxScore: 6 },
1_020,
)).toEqual({ retained: 0, rejected: 1 });
expect(queue.listActive()).toEqual([
expect.objectContaining({ postId: sending.postId, status: 'sending' }),
]);
});
it('lists only active candidates in newest-post order across stages', () => { it('lists only active candidates in newest-post order across stages', () => {
queue.enqueue([ queue.enqueue([
post('waiting', 'alice', 100), post('waiting', 'alice', 100),
+52
View File
@@ -357,6 +357,58 @@ describe('RedditApiClient', () => {
expect(result).toBe(true); expect(result).toBe(true);
}); });
it('detects a configured seller platform in structured social links', async () => {
const get = vi.fn().mockResolvedValue({
data: {
data: {
subreddit: { public_description: 'Just me', over_18: true },
social_links: [{ title: 'My OnlyFans', outbound_url: 'https://onlyfans.com/example' }],
},
},
});
vi.mocked(axios.create).mockReturnValue({ get } as any);
const client2 = new RedditApiClient(getTestConfig(), logger);
await expect(client2.isSellerProfile('testuser', ['onlyfans'], ['onlyfans.com']))
.resolves.toBe(true);
expect(get).toHaveBeenCalledTimes(1);
});
it('detects a narrow NSFW link-to-more funnel when Reddit omits the social link', async () => {
const get = vi.fn().mockResolvedValue({
data: {
data: {
subreddit: {
public_description: "Hit the link to see more of me, we both know why you're here",
title: '',
display_name_prefixed: 'u/testuser',
over_18: true,
},
},
},
});
vi.mocked(axios.create).mockReturnValue({ get } as any);
const client2 = new RedditApiClient(getTestConfig(), logger);
await expect(client2.isSellerProfile('testuser', ['onlyfans'], ['onlyfans.com']))
.resolves.toBe(true);
expect(get).toHaveBeenCalledTimes(1);
});
it('does not treat an ordinary or non-NSFW profile link as a seller funnel', async () => {
const get = vi.fn()
.mockResolvedValueOnce({
data: { data: { subreddit: { public_description: 'Hit the link to see more of my art', over_18: false } } },
})
.mockResolvedValueOnce({ data: { data: { children: [] } } });
vi.mocked(axios.create).mockReturnValue({ get } as any);
const client2 = new RedditApiClient(getTestConfig(), logger);
await expect(client2.isSellerProfile('testuser', ['onlyfans'], ['onlyfans.com']))
.resolves.toBe(false);
expect(get).toHaveBeenCalledTimes(2);
});
it('returns false when no keywords match', async () => { it('returns false when no keywords match', async () => {
const mockResponse = { const mockResponse = {
data: { data: {
+11 -3
View File
@@ -25,10 +25,11 @@ function harness() {
// harnesses would otherwise share the same `conversations` instance. // harnesses would otherwise share the same `conversations` instance.
const config = structuredClone(ConfigSchema.parse(getTestConfig())) as Config; const config = structuredClone(ConfigSchema.parse(getTestConfig())) as Config;
const saved: Config[] = []; const saved: Config[] = [];
const candidateQueue = { reconcilePhysiqueTargeting: vi.fn(() => ({ retained: 2, rejected: 1 })) };
const ctx = { const ctx = {
config, config,
runtimeConfig: config, runtimeConfig: config,
services: { configStore: { save: (next: Config) => saved.push(next) } }, services: { configStore: { save: (next: Config) => saved.push(next) }, candidateQueue },
getOnConfigChange: () => undefined, getOnConfigChange: () => undefined,
activity: { push: vi.fn() }, activity: { push: vi.fn() },
broadcaster: { push: vi.fn() }, broadcaster: { push: vi.fn() },
@@ -37,7 +38,7 @@ function harness() {
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
app.use('/api', createConfigRouter(ctx)); app.use('/api', createConfigRouter(ctx));
return { app, config, saved }; return { app, config, saved, candidateQueue, ctx };
} }
describe('config route conversation kill controls', () => { describe('config route conversation kill controls', () => {
@@ -112,7 +113,7 @@ describe('config route conversation kill controls', () => {
describe('config route physique targeting', () => { describe('config route physique targeting', () => {
it('hot-updates and persists the image-candidate score range', async () => { it('hot-updates and persists the image-candidate score range', async () => {
const { app, config, saved } = harness(); const { app, config, saved, candidateQueue, ctx } = harness();
const targetingRef = config.reddit.physiqueTargeting; const targetingRef = config.reddit.physiqueTargeting;
const response = await request(app, '/api/config', { const response = await request(app, '/api/config', {
@@ -129,6 +130,13 @@ describe('config route physique targeting', () => {
expect(config.reddit.physiqueTargeting).toBe(targetingRef); expect(config.reddit.physiqueTargeting).toBe(targetingRef);
expect(config.reddit.physiqueTargeting).toEqual({ enabled: true, minScore: 3, maxScore: 7 }); expect(config.reddit.physiqueTargeting).toEqual({ enabled: true, minScore: 3, maxScore: 7 });
expect(saved.at(-1)?.reddit.physiqueTargeting).toEqual({ enabled: true, minScore: 3, maxScore: 7 }); expect(saved.at(-1)?.reddit.physiqueTargeting).toEqual({ enabled: true, minScore: 3, maxScore: 7 });
expect(candidateQueue.reconcilePhysiqueTargeting).toHaveBeenCalledWith({
enabled: true, minScore: 3, maxScore: 7,
});
expect(ctx.activity.push).toHaveBeenCalledWith(
'info',
'Physique targeting reconciled 2 prepared image candidate(s) and rejected 1',
);
}); });
it('rejects an inverted physique range without mutating configuration', async () => { it('rejects an inverted physique range without mutating configuration', async () => {