UNPKG

fdp

Version:

Finite Domain Problem reduction system

531 lines (456 loc) 20.1 kB
// import dsl // generate ml // minimize -> reduce constraints // generate propagators // stabilize // exit import { $CHANGED, $REJECTED, $SOLVED, $STABLE, ASSERT, ASSERT_NORDOM, getTerm, INSPECT, setTerm, TRACE, THROW, } from '../../fdlib/src/helpers'; import { domain__debug, domain_arrToSmallest, domain_containsValue, domain_createValue, domain_getValue, domain_intersection, domain_max, domain_middleElement, domain_min, domain_toArr, } from '../../fdlib/src/domain'; import { ml_countConstraints, ml_getOpList, ml_hasConstraint, } from './ml'; import { dsl2ml, } from './dsl2ml'; import { ml2dsl, } from './ml2dsl'; import { min_run, } from './minimizer'; import { deduper, } from './deduper'; import { cutter, } from './cutter'; import { problem_create, //problem_from, } from './problem'; import { bounty_collect, } from './bounty'; // BODY_START let FDP = { solve: fdpSolver, }; /** * @param {string} dsl The input problem * @param {Function} solver The function to brute force the remainder of the problem after FDP reduces it, not called if already solved. Called with `solver(dsl, options)`. * @param {Object} fdpOptions * @property {boolean} [fdpOptions.singleCycle=false] Only do a single-nonloop minimization step before solving? Can be faster but sloppier. * @property {boolean} [fdpOptions.repeatUntilStable=true] Keep calling minimize/cutter per cycle until nothing changes? * @property {boolean} [fdpOptions.debugDsl=false] Enable debug output (adds lots of comments about vars) * @property {boolean} [fdpOptions.hashNames=true] Replace original varNames with `$<base36(index)>$` of their index in the output * @property {boolean} [fdpOptions.indexNames=false] Replace original varNames with `_<index>_` in the output * @property {boolean} [fdpOptions.groupedConstraints=true] When debugging only, add all constraints below a var decl where that var is used * @property {boolean} [fdpOptions.flattened=false] Solve all vars in the solution even if there are multiple open fdpOptions left * @property {boolean|Function} [fdpOptions.printDslBefore] Print the dsl after parsing it but before crunching it. * @property {boolean|Function} [fdpOptions.printDslAfter] Print the dsl after crunching it but before calling FD on it * @param {Object} solverOptions Passed on to the solver directly */ function fdpSolver(dsl, solver, fdpOptions = {}, solverOptions = {log: 1, vars: 'all'}) { ASSERT(typeof dsl === 'string'); ASSERT(typeof fdpOptions !== 'function', 'confirming this isnt the old solver param'); //fdpOptions.hashNames = false; //fdpOptions.repeatUntilStable = true; //fdpOptions.debugDsl = false; //fdpOptions.singleCycle = true; //fdpOptions.indexNames = true; //fdpOptions.groupedConstraints = true; if (solverOptions.logger) setTerm(solverOptions.logger); let term = getTerm(); term.log('<pre>'); term.time('</pre>'); let r = _preSolver(dsl, solver, fdpOptions, solverOptions); term.timeEnd('</pre>'); return r; } function _preSolver(dsl, solver, options, solveOptions) { ASSERT(typeof dsl === 'string'); ASSERT(typeof options !== 'function', 'making sure this isnt the old Solver param'); let term = getTerm(); term.log('<pre-solving>'); term.time('</pre-solving total>'); let { hashNames = true, debugDsl = false, indexNames = false, groupedConstraints = true, } = options; if (options.hashNames === undefined) options.hashNames = hashNames; if (options.debugDsl === undefined) options.debugDsl = debugDsl; if (options.indexNames === undefined) options.indexNames = indexNames; if (options.groupedConstraints === undefined) options.groupedConstraints = groupedConstraints; let problem = problem_create(); let { varNames, domains, } = problem; TRACE(dsl.slice(0, 1000) + (dsl.length > 1000 ? ' ... <trimmed>' : '') + '\n'); let state = crunch(dsl, problem, options); let bounty; let betweenDsl; if (state === $REJECTED) { TRACE('Skipping ml2dsl because problem rejected and bounty/ml2dsl dont handle empty domains well'); } else { term.time('ml->dsl'); bounty = bounty_collect(problem.ml, problem); betweenDsl = ml2dsl(problem.ml, problem, bounty, {debugDsl: false, hashNames: true}); // use default generator settings for dsl to pass on to FD term.timeEnd('ml->dsl'); } term.timeEnd('</pre-solving total>'); if (state === $REJECTED) term.log('REJECTED'); //term.log(domains.map((d,i) => i+':'+problem.targeted[i]).join(', ')); // confirm domains has no gaps... //term.log(problem.domains) //for (let i=0; i<domains.length; ++i) { // ASSERT(i in domains, 'no gaps'); // ASSERT(domains[i] !== undefined, 'no pseudo gaps'); //} // cutter cant reject, only reduce. may eliminate the last standing constraints. let solution; if (state === $SOLVED || (state !== $REJECTED && !ml_hasConstraint(problem.ml))) { term.time('- generating early solution'); solution = createSolution(problem, null, options, solveOptions.max || Infinity); term.timeEnd('- generating early solution'); } if (state !== $REJECTED && ((betweenDsl && betweenDsl.length < 1000) || options.printDslAfter)) { let dslForLogging = ml2dsl(problem.ml, problem, bounty, options); let s = '\nResult dsl (debugDsl=' + debugDsl + ', hashNames=' + hashNames + ', indexNames=' + indexNames + '):\n' + dslForLogging; if (typeof options.printDslAfter === 'function') { options.printDslAfter(s); } else { term.log('#### <DSL> after crunching before FD'); term.log(s); term.log('#### </DSL>'); } } if (solution) { term.error('<solved without fdq>'); return solution; } if (state === $REJECTED) { term.error('<rejected without fdq>'); TRACE('problem rejected!'); return 'rejected'; } if (problem.input.varstrat === 'throw') { // the stats are for tests. dist will never even have this so this should be fine. // it's very difficult to ensure optimizations work properly otherwise ASSERT(false, `Forcing a choice with strat=throw; debug: ${varNames.length} vars, ${ml_countConstraints(problem.ml)} constraints, current domain state: ${domains.map((d, i) => i + ':' + varNames[i] + ':' + domain__debug(d).replace(/[a-z()\[\]]/g, '')).join(': ')} (${problem.leafs.length} leafs) ops: ${ml_getOpList(problem.ml)} #`); THROW('Forcing a choice with strat=throw'); } term.error('\n\nSolving remaining problem through fdq now...'); term.log('<FD>'); term.time('</FD>'); let fdSolution = solver(betweenDsl, solveOptions); term.timeEnd('</FD>'); term.log('\n'); // Now merge the results from fdSolution to construct the final solution // we need to map the vars from the dsl back to the original names. // "our" vars will be constructed like `$<hash>$` where the hash simply // means "our" var index as base36. So all we need to do is remove the // dollar signs and parseInt as base 36. Ignore all other vars as they // are temporary vars generated by fdq. We should not see them // anymore once we support targeted vars. term.log('fd result:', typeof fdSolution === 'string' ? fdSolution : 'SOLVED'); TRACE('fdSolution = ', fdSolution ? Object.keys(fdSolution).length > 100 ? '<supressed; too big>' : fdSolution : 'REJECT'); if (fdSolution && typeof fdSolution !== 'string') { term.error('<solved after fdq>'); return createSolution(problem, fdSolution, options, solveOptions.max || Infinity); } term.error('<' + fdSolution + ' during fdq>'); TRACE('problem rejected!'); return 'rejected'; } function crunch(dsl, problem, options = {}) { let { singleCycle = false, repeatUntilStable = true, } = options; let { varNames, domains, solveStack, $addVar, $getVar, $addAlias, $getAlias, } = problem; let term = getTerm(); term.time('- dsl->ml'); dsl2ml(dsl, problem); let ml = problem.ml; term.timeEnd('- dsl->ml'); term.log('Parsed dsl (' + dsl.length + ' bytes) into ml (' + ml.length + ' bytes)'); if (options.printDslBefore) { let bounty = bounty_collect(problem.ml, problem); let predsl = ml2dsl(ml, problem, bounty, options); if (typeof options.printDslBefore === 'function') { options.printDslBefore(predsl); } else { term.log('#### <DSL> after parsing before crunching'); term.log(predsl); term.log('#### </DSL>'); } } let state; if (singleCycle) { // only single cycle? usually most dramatic reduction. only runs a single loop of every step. term.time('- first minimizer cycle (single loop)'); state = min_run(ml, problem, domains, varNames, true, !repeatUntilStable); term.timeEnd('- first minimizer cycle (single loop)'); TRACE('First minimize pass result:', state); if (state !== $REJECTED) { term.time('- deduper cycle #'); let deduperAddedAlias = deduper(ml, problem); term.timeEnd('- deduper cycle #'); if (deduperAddedAlias >= 0) { term.time('- cutter cycle #'); cutter(ml, problem, !repeatUntilStable); term.timeEnd('- cutter cycle #'); } } } else { // multiple cycles? more expensive, may not be worth the gains let runLoops = 0; term.time('- all run cycles'); do { TRACE('run loop...'); state = run_cycle(ml, $getVar, $addVar, domains, varNames, $addAlias, $getAlias, solveStack, runLoops++, problem); } while (state === $CHANGED); term.timeEnd('- all run cycles'); } return state; } function run_cycle(ml, getVar, addVar, domains, vars, addAlias, getAlias, solveStack, runLoops, problem) { let term = getTerm(); term.time('- run_cycle #' + runLoops); term.time('- minimizer cycle #' + runLoops); let state = min_run(ml, problem, domains, vars, runLoops === 0); term.timeEnd('- minimizer cycle #' + runLoops); if (state === $SOLVED) return state; if (state === $REJECTED) return state; term.time('- deduper cycle #' + runLoops); let deduperAddedAlias = deduper(ml, problem); term.timeEnd('- deduper cycle #' + runLoops); if (deduperAddedAlias < 0) { state = $REJECTED; } else { term.time('- cutter cycle #' + runLoops); let cutLoops = cutter(ml, problem, false); term.timeEnd('- cutter cycle #' + runLoops); if (cutLoops > 1 || deduperAddedAlias) state = $CHANGED; else if (cutLoops < 0) state = $REJECTED; else { ASSERT(state === $CHANGED || state === $STABLE, 'minimize state should be either stable or changed here'); } } term.timeEnd('- run_cycle #' + runLoops); return state; } function createSolution(problem, fdSolution, options, max) { getTerm().time('createSolution()'); let { flattened = false, } = options; let { varNames, domains, solveStack, getAlias, targeted, } = problem; let _getDomainWithoutFd = problem.getDomain; let _setDomainWithoutFd = problem.setDomain; function getDomainFromSolverOrLocal(index, skipAliasCheck) { if (!skipAliasCheck) index = getAlias(index); if (fdSolution) { let key = '$' + index.toString(36) + '$'; let fdval = fdSolution[key]; if (typeof fdval === 'number') { return domain_createValue(fdval); } else if (fdval !== undefined) { ASSERT(fdval instanceof Array, 'expecting fdq to only create solutions as arrays or numbers', fdval); return domain_arrToSmallest(fdval); } // else the var was already solved by fd2 so just read from our local domains array } return _getDomainWithoutFd(index, true); } function setDomainInFdAndLocal(index, domain, skipAliasCheck, forPseudoAlias) { TRACE(' - solveStackSetDomain, index=', index, ', domain=', domain__debug(domain), ', skipAliasCheck=', skipAliasCheck, ', forPseudoAlias=', forPseudoAlias); ASSERT(domain, 'should not set an empty domain at this point'); ASSERT(forPseudoAlias || domain_intersection(_getDomainWithoutFd(index), domain) === domain, 'should not introduce values into the domain that did not exist before unless for xnor pseudo-booly; current:', domain__debug(_getDomainWithoutFd(index)), ', updating to:', domain__debug(domain), 'varIndex:', index); if (!skipAliasCheck) index = getAlias(index); _setDomainWithoutFd(index, domain, true, false, forPseudoAlias); // update the FD result AND the local data structure to reflect this new domain // the FD value rules when checking intersection with the new domain // (but we can just use the getter abstraction here and overwrite regardless) if (fdSolution) { let key = '$' + index.toString(36) + '$'; if (fdSolution[key] !== undefined) { let v = domain_getValue(domain); if (v >= 0) fdSolution[key] = v; else fdSolution[key] = domain_toArr(domain); } } } function force(varIndex, pseudoDomain) { ASSERT(typeof varIndex === 'number' && varIndex >= 0 && varIndex <= 0xffff, 'valid var to solve', varIndex); let finalVarIndex = getAlias(varIndex); let domain = getDomainFromSolverOrLocal(finalVarIndex, true); // NOTE: this will take from fdSolution if it contains a value, otherwise from local domains ASSERT_NORDOM(domain); ASSERT(pseudoDomain === undefined || domain_intersection(pseudoDomain, domain) === pseudoDomain, 'pseudoDomain should not introduce new values'); let v = domain_getValue(domain); if (v < 0) { if (pseudoDomain) { TRACE(' - force() using pseudo domain', domain__debug(pseudoDomain), 'instead of actual domain', domain__debug(domain)); domain = pseudoDomain; } TRACE(' - forcing index', varIndex, '(final index=', finalVarIndex, ') to min(' + domain__debug(domain) + '):', domain_min(domain)); let dist = problem.valdist[varIndex]; if (dist) { ASSERT(typeof dist === 'object', 'dist is an object'); ASSERT(typeof dist.valtype === 'string', 'dist object should have a name'); // TODO: rename valtype to "name"? or maybe keep it this way because easier to search for anyways. *shrug* switch (dist.valtype) { case 'list': ASSERT(dist.list instanceof Array, 'lists should have a prio'); dist.list.some(w => domain_containsValue(domain, w) && (v = w) >= 0); if (v < 0) v = domain_min(domain); // none of the prioritized values still exist so just pick one break; case 'max': v = domain_max(domain); break; case 'min': case 'naive': v = domain_min(domain); break; case 'mid': v = domain_middleElement(domain); break; case 'markov': case 'minMaxCycle': case 'splitMax': case 'splitMin': THROW('implement me (var mod) [' + dist.valtype + ']'); v = domain_min(domain); break; default: THROW('Unknown dist name: [' + dist.valtype + ']', dist); } } else { // just an arbitrary choice then v = domain_min(domain); } ASSERT(domain_containsValue(domain, v), 'force() should not introduce new values'); setDomainInFdAndLocal(varIndex, domain_createValue(v), true); } return v; } TRACE('\n# createSolution(), solveStack.length=', solveStack.length, ', using fdSolution?', !!fdSolution); TRACE(' - fdSolution:', domains.length < 50 ? INSPECT(fdSolution).replace(/\n/g, '') : '<big>'); TRACE(' - domains:', domains.length < 50 ? domains.map((_, i) => '{index=' + i + ',name=' + problem.varNames[i] + ',' + domain__debug(problem.getDomain(i)) + '}').join(', ') : '<big>'); ASSERT(domains.length < 50 || !void TRACE('domains before; index, unaliased, aliased, fdSolution (if any):\n', domains.map((d, i) => i + ': ' + domain__debug(d) + ', ' + domain__debug(_getDomainWithoutFd(i)) + ', ' + domain__debug(getDomainFromSolverOrLocal(i))))); function flushSolveStack() { TRACE('Flushing solve stack...', solveStack.length ? '' : ' and done! (solve stack was empty)'); let rev = solveStack.reverse(); for (let i = 0; i < rev.length; ++i) { let f = rev[i]; TRACE('- solve stack entry', i); f(domains, force, getDomainFromSolverOrLocal, setDomainInFdAndLocal); TRACE(domains.length < 50 ? ' - domains now: ' + domains.map((_, i) => '{index=' + i + ',name=' + problem.varNames[i] + ',' + domain__debug(problem.getDomain(i)) + '}').join(', ') : ''); } ASSERT(domains.length < 50 || !void TRACE('domains after solve stack flush; index, unaliased, aliased, fdSolution (if any):\n', domains.map((d, i) => i + ': ' + domain__debug(d) + ', ' + domain__debug(_getDomainWithoutFd(i)) + ', ' + domain__debug(getDomainFromSolverOrLocal(i))))); } flushSolveStack(); ASSERT(!void domains.forEach((d, i) => ASSERT(domains[i] === false ? getAlias(i) !== i : ASSERT_NORDOM(d), 'domains should be aliased or nordom at this point', 'index=' + i, ', alias=', getAlias(i), ', domain=' + domain__debug(d), domains))); function flushValDists() { TRACE('\n# flushValDists: One last loop through all vars to force those with a valdist'); for (let i = 0; i < domains.length; ++i) { if (flattened || problem.valdist[i]) { // can ignore FD here (I think) _setDomainWithoutFd(i, domain_createValue(force(i)), true); } else { // TOFIX: make this more efficient... (cache the domain somehow) let domain = getDomainFromSolverOrLocal(i); let v = domain_getValue(domain); if (v >= 0) { // can ignore FD here (I think) _setDomainWithoutFd(i, domain, true); } } } } flushValDists(); TRACE('\n'); ASSERT(domains.length < 50 || !void TRACE('domains after dist pops; index, unaliased, aliased, fdSolution (if any):\n', domains.map((d, i) => i + ': ' + domain__debug(d) + ', ' + domain__debug(_getDomainWithoutFd(i)) + ', ' + domain__debug(getDomainFromSolverOrLocal(i))))); ASSERT(!void domains.forEach((d, i) => ASSERT(d === false ? getAlias(i) !== i : (flattened ? domain_getValue(d) >= 0 : ASSERT_NORDOM(d)), 'domains should be aliased or nordom at this point', 'index=' + i, ', alias=', getAlias(i), 'domain=' + domain__debug(d), domains))); function flushAliases() { TRACE(' - syncing aliases'); for (let i = 0; i < domains.length; ++i) { let d = domains[i]; if (d === false) { let a = getAlias(i); let v = force(a); TRACE('Forcing', i, 'and', a, 'to be equal because they are aliased, resulting value=', v); // can ignore FD here (I think) _setDomainWithoutFd(i, domain_createValue(v), true); } } } flushAliases(); ASSERT(domains.length < 50 || !void TRACE('domains after dealiasing; index, unaliased, aliased, fdSolution (if any):\n', domains.map((d, i) => i + ': ' + domain__debug(d) + ', ' + domain__debug(_getDomainWithoutFd(i)) + ', ' + domain__debug(getDomainFromSolverOrLocal(i))))); function generateFinalSolution() { TRACE(' - generating regular FINAL solution', flattened); let solution = {}; for (let index = 0; index < varNames.length; ++index) { if (targeted[index]) { let name = varNames[index]; let d = getDomainFromSolverOrLocal(index); let v = domain_getValue(d); if (v >= 0) { d = v; } else if (flattened) { ASSERT(!problem.valdist[index], 'only vars without valdist may not be solved at this point'); d = domain_min(d); } else { d = domain_toArr(d); } solution[name] = d; } } return solution; } let solution = generateFinalSolution(); getTerm().timeEnd('createSolution()'); TRACE(' -> createSolution results in:', domains.length > 100 ? '<supressed; too many vars (' + domains.length + ')>' : solution); return solution; } // BODY_STOP export default FDP;